figma-cli 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {figma_cli-0.2.0 → figma_cli-0.3.0}/PKG-INFO +26 -4
- {figma_cli-0.2.0 → figma_cli-0.3.0}/README.md +25 -3
- {figma_cli-0.2.0 → figma_cli-0.3.0}/src/figma_cli/__init__.py +1 -1
- {figma_cli-0.2.0 → figma_cli-0.3.0}/src/figma_cli/cli.py +44 -7
- {figma_cli-0.2.0 → figma_cli-0.3.0}/src/figma_cli/client.py +29 -5
- figma_cli-0.3.0/src/figma_cli/login.py +108 -0
- figma_cli-0.3.0/tests/conftest.py +11 -0
- {figma_cli-0.2.0 → figma_cli-0.3.0}/tests/test_cli.py +111 -1
- {figma_cli-0.2.0 → figma_cli-0.3.0}/tests/test_client.py +162 -1
- {figma_cli-0.2.0 → figma_cli-0.3.0}/tests/test_live.py +23 -4
- {figma_cli-0.2.0 → figma_cli-0.3.0}/.github/workflows/ci.yml +0 -0
- {figma_cli-0.2.0 → figma_cli-0.3.0}/.github/workflows/release.yml +0 -0
- {figma_cli-0.2.0 → figma_cli-0.3.0}/.gitignore +0 -0
- {figma_cli-0.2.0 → figma_cli-0.3.0}/LICENSE +0 -0
- {figma_cli-0.2.0 → figma_cli-0.3.0}/pyproject.toml +0 -0
- {figma_cli-0.2.0 → figma_cli-0.3.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: figma-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Headless Figma CLI for AI coding agents and automated design inspection.
|
|
5
5
|
Project-URL: Homepage, https://github.com/imperfect-co/figma-cli
|
|
6
6
|
Project-URL: Issues, https://github.com/imperfect-co/figma-cli/issues
|
|
@@ -49,17 +49,39 @@ The npm package `silships/figma-cli` also installs a `figma-cli` executable. If
|
|
|
49
49
|
|
|
50
50
|
## Usage
|
|
51
51
|
|
|
52
|
-
Every command talks to the Figma REST API with a personal access token. Create one under Figma account settings
|
|
52
|
+
Every command talks to the Figma REST API with a personal access token. Create one under Figma account settings (Security > Personal access tokens) with these scopes, the minimum the commands below need per [Figma's scope reference](https://developers.figma.com/docs/rest-api/scopes/):
|
|
53
|
+
|
|
54
|
+
| Scope | Used by |
|
|
55
|
+
| --- | --- |
|
|
56
|
+
| `current_user:read` | `auth check`, `auth login` (`GET /v1/me`) |
|
|
57
|
+
| `file_content:read` | `file get`, `export` |
|
|
58
|
+
| `file_comments:read` | `comment list` |
|
|
59
|
+
| `file_comments:write` | `comment post`, `comment delete` |
|
|
60
|
+
|
|
61
|
+
Then store it once with `figma auth login`:
|
|
62
|
+
|
|
63
|
+
```sh
|
|
64
|
+
figma auth login # interactive: prints the steps, opens settings, hidden prompt
|
|
65
|
+
echo "$TOKEN" | figma auth login # agents and CI: piped stdin
|
|
66
|
+
figma auth login --token - < token.txt # same, explicit
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`auth login` validates the token against `GET /v1/me` before writing anything. A rejected token exits 3 and leaves any existing token file untouched. A valid one is written to `~/.config/figma/token` with mode `0600` (its directory `0700`), through a temporary file renamed into place, so a failed write keeps the previous token, and the command reports the authenticated `id`, `handle` and `email` plus the file path. Prefer stdin over `--token <value>`, which exposes the token in process listings and shell history. Empty stdin exits 2. Add `--no-browser` to skip opening the settings page.
|
|
70
|
+
|
|
71
|
+
Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored file whenever it is set and non-empty:
|
|
53
72
|
|
|
54
73
|
```sh
|
|
55
74
|
export FIGMA_TOKEN=figd_...
|
|
56
75
|
export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
|
|
57
76
|
```
|
|
58
77
|
|
|
78
|
+
With neither set, commands fail with `{"error": "missing_token"}` and exit code 3.
|
|
79
|
+
|
|
59
80
|
Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
|
|
60
81
|
|
|
61
82
|
| Command | Figma endpoint |
|
|
62
83
|
| --- | --- |
|
|
84
|
+
| `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
|
|
63
85
|
| `figma auth check` | `GET /v1/me` |
|
|
64
86
|
| `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
|
|
65
87
|
| `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
|
|
@@ -161,9 +183,9 @@ ruff format --check .
|
|
|
161
183
|
pytest tests/
|
|
162
184
|
```
|
|
163
185
|
|
|
164
|
-
The tests are hermetic: no network access and no Figma token are needed. The redirect tests use real sockets on `127.0.0.1`.
|
|
186
|
+
The tests are hermetic: no network access and no Figma token are needed, and `HOME` points at a temporary directory so a stored token never leaks in. The redirect tests use real sockets on `127.0.0.1`.
|
|
165
187
|
|
|
166
|
-
`tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` and `
|
|
188
|
+
`tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` is set. With the token alone it checks `auth check` and runs `auth login` from stdin into a temporary `HOME`; the file, comment and export checks also need `FIGMA_TEST_FILE_KEY` (add `FIGMA_TEST_NODE_ID` to exercise export). The comment check posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
|
|
167
189
|
|
|
168
190
|
## Release
|
|
169
191
|
|
|
@@ -24,17 +24,39 @@ The npm package `silships/figma-cli` also installs a `figma-cli` executable. If
|
|
|
24
24
|
|
|
25
25
|
## Usage
|
|
26
26
|
|
|
27
|
-
Every command talks to the Figma REST API with a personal access token. Create one under Figma account settings
|
|
27
|
+
Every command talks to the Figma REST API with a personal access token. Create one under Figma account settings (Security > Personal access tokens) with these scopes, the minimum the commands below need per [Figma's scope reference](https://developers.figma.com/docs/rest-api/scopes/):
|
|
28
|
+
|
|
29
|
+
| Scope | Used by |
|
|
30
|
+
| --- | --- |
|
|
31
|
+
| `current_user:read` | `auth check`, `auth login` (`GET /v1/me`) |
|
|
32
|
+
| `file_content:read` | `file get`, `export` |
|
|
33
|
+
| `file_comments:read` | `comment list` |
|
|
34
|
+
| `file_comments:write` | `comment post`, `comment delete` |
|
|
35
|
+
|
|
36
|
+
Then store it once with `figma auth login`:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
figma auth login # interactive: prints the steps, opens settings, hidden prompt
|
|
40
|
+
echo "$TOKEN" | figma auth login # agents and CI: piped stdin
|
|
41
|
+
figma auth login --token - < token.txt # same, explicit
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`auth login` validates the token against `GET /v1/me` before writing anything. A rejected token exits 3 and leaves any existing token file untouched. A valid one is written to `~/.config/figma/token` with mode `0600` (its directory `0700`), through a temporary file renamed into place, so a failed write keeps the previous token, and the command reports the authenticated `id`, `handle` and `email` plus the file path. Prefer stdin over `--token <value>`, which exposes the token in process listings and shell history. Empty stdin exits 2. Add `--no-browser` to skip opening the settings page.
|
|
45
|
+
|
|
46
|
+
Alternatively, export the token. `FIGMA_TOKEN` takes precedence over the stored file whenever it is set and non-empty:
|
|
28
47
|
|
|
29
48
|
```sh
|
|
30
49
|
export FIGMA_TOKEN=figd_...
|
|
31
50
|
export FIGMA_API_BASE=https://api.figma.com # optional, this is the default
|
|
32
51
|
```
|
|
33
52
|
|
|
53
|
+
With neither set, commands fail with `{"error": "missing_token"}` and exit code 3.
|
|
54
|
+
|
|
34
55
|
Every subcommand prints human-readable text by default and a JSON document on stdout with `--json`.
|
|
35
56
|
|
|
36
57
|
| Command | Figma endpoint |
|
|
37
58
|
| --- | --- |
|
|
59
|
+
| `figma auth login [--token TOKEN\|-] [--no-browser]` | `GET /v1/me`, then writes `~/.config/figma/token` |
|
|
38
60
|
| `figma auth check` | `GET /v1/me` |
|
|
39
61
|
| `figma file get <file_key> [--depth N]` | `GET /v1/files/{file_key}?depth=N` |
|
|
40
62
|
| `figma export <file_key> --nodes <ids> [--format png\|svg] [--output DIR]` | `GET /v1/images/{file_key}`, then the asset URLs |
|
|
@@ -136,9 +158,9 @@ ruff format --check .
|
|
|
136
158
|
pytest tests/
|
|
137
159
|
```
|
|
138
160
|
|
|
139
|
-
The tests are hermetic: no network access and no Figma token are needed. The redirect tests use real sockets on `127.0.0.1`.
|
|
161
|
+
The tests are hermetic: no network access and no Figma token are needed, and `HOME` points at a temporary directory so a stored token never leaks in. The redirect tests use real sockets on `127.0.0.1`.
|
|
140
162
|
|
|
141
|
-
`tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` and `
|
|
163
|
+
`tests/test_live.py` runs against the real API only when `FIGMA_TOKEN` is set. With the token alone it checks `auth check` and runs `auth login` from stdin into a temporary `HOME`; the file, comment and export checks also need `FIGMA_TEST_FILE_KEY` (add `FIGMA_TEST_NODE_ID` to exercise export). The comment check posts one comment and deletes it. CI runs it in the `live` job from the `FIGMA_TOKEN` secret and the `FIGMA_TEST_FILE_KEY` / `FIGMA_TEST_NODE_ID` repository variables, and skips it when they are absent.
|
|
142
164
|
|
|
143
165
|
## Release
|
|
144
166
|
|
|
@@ -13,16 +13,17 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from figma_cli import __version__
|
|
15
15
|
from figma_cli.client import FigmaClient, FigmaError, download
|
|
16
|
+
from figma_cli.login import login
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
# Most handlers take (client, args); those marked needs_client=False take (args).
|
|
19
|
+
Handler = Callable[..., tuple[Any, str]]
|
|
18
20
|
|
|
19
21
|
|
|
20
22
|
def _print_json(payload: Any) -> None:
|
|
21
23
|
print(json.dumps(payload, indent=2, ensure_ascii=False))
|
|
22
24
|
|
|
23
25
|
|
|
24
|
-
def
|
|
25
|
-
me = client.me()
|
|
26
|
+
def _identity(me: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
|
26
27
|
ident = {key: me.get(key) for key in ("id", "handle", "email")}
|
|
27
28
|
return (
|
|
28
29
|
ident,
|
|
@@ -30,6 +31,16 @@ def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str
|
|
|
30
31
|
)
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def _auth_check(client: FigmaClient, args: argparse.Namespace) -> tuple[Any, str]:
|
|
35
|
+
return _identity(client.me())
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _auth_login(args: argparse.Namespace) -> tuple[Any, str]:
|
|
39
|
+
me, path = login(args)
|
|
40
|
+
ident, text = _identity(me)
|
|
41
|
+
return {**ident, "token_path": str(path)}, f"{text}\nToken saved to {path}"
|
|
42
|
+
|
|
43
|
+
|
|
33
44
|
def _tree_lines(node: dict[str, Any], indent: int = 0) -> list[str]:
|
|
34
45
|
label = f"{node.get('type', '?')} {node.get('name', '')} ({node.get('id', '')})"
|
|
35
46
|
lines = [" " * indent + label]
|
|
@@ -125,7 +136,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
125
136
|
description=(
|
|
126
137
|
"Headless Figma CLI for AI coding agents and automated design inspection."
|
|
127
138
|
),
|
|
128
|
-
epilog=
|
|
139
|
+
epilog=(
|
|
140
|
+
"Environment: FIGMA_TOKEN (optional when 'figma auth login' has stored"
|
|
141
|
+
" ~/.config/figma/token), FIGMA_API_BASE (optional)."
|
|
142
|
+
),
|
|
129
143
|
)
|
|
130
144
|
parser.add_argument(
|
|
131
145
|
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
@@ -134,10 +148,30 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
134
148
|
common.add_argument("--json", action="store_true", help="print structured JSON")
|
|
135
149
|
commands = parser.add_subparsers(dest="command", metavar="<command>")
|
|
136
150
|
|
|
137
|
-
auth = commands.add_parser("auth", help="token checks")
|
|
151
|
+
auth = commands.add_parser("auth", help="token setup and checks")
|
|
138
152
|
auth_cmds = auth.add_subparsers(dest="action", metavar="<action>", required=True)
|
|
139
|
-
p = auth_cmds.add_parser(
|
|
153
|
+
p = auth_cmds.add_parser(
|
|
154
|
+
"check", parents=[common], help="validate FIGMA_TOKEN or stored token file"
|
|
155
|
+
)
|
|
140
156
|
p.set_defaults(handler=_auth_check)
|
|
157
|
+
p = auth_cmds.add_parser(
|
|
158
|
+
"login",
|
|
159
|
+
parents=[common],
|
|
160
|
+
help="validate a token and store it in ~/.config/figma/token",
|
|
161
|
+
)
|
|
162
|
+
p.add_argument(
|
|
163
|
+
"--token",
|
|
164
|
+
help=(
|
|
165
|
+
"token value, or '-' to read stdin; a literal value shows in ps and"
|
|
166
|
+
" shell history, so prefer piped stdin, which is read without this flag"
|
|
167
|
+
),
|
|
168
|
+
)
|
|
169
|
+
p.add_argument(
|
|
170
|
+
"--browser",
|
|
171
|
+
action=argparse.BooleanOptionalAction,
|
|
172
|
+
help="open Figma settings when prompting interactively (default: open)",
|
|
173
|
+
)
|
|
174
|
+
p.set_defaults(handler=_auth_login, needs_client=False)
|
|
141
175
|
|
|
142
176
|
file = commands.add_parser("file", help="file inspection")
|
|
143
177
|
file_cmds = file.add_subparsers(dest="action", metavar="<action>", required=True)
|
|
@@ -194,7 +228,10 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
194
228
|
parser.print_help()
|
|
195
229
|
return 0
|
|
196
230
|
try:
|
|
197
|
-
|
|
231
|
+
if getattr(args, "needs_client", True):
|
|
232
|
+
payload, text = handler(FigmaClient.from_env(), args)
|
|
233
|
+
else:
|
|
234
|
+
payload, text = handler(args)
|
|
198
235
|
except FigmaError as err:
|
|
199
236
|
if args.json:
|
|
200
237
|
_print_json(err.payload)
|
|
@@ -14,11 +14,13 @@ import time
|
|
|
14
14
|
import urllib.error
|
|
15
15
|
import urllib.parse
|
|
16
16
|
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
17
18
|
from typing import Any
|
|
18
19
|
|
|
19
20
|
DEFAULT_API_BASE = "https://api.figma.com"
|
|
20
21
|
TOKEN_HEADER = "X-Figma-Token"
|
|
21
22
|
TIMEOUT_SECONDS = 60
|
|
23
|
+
EXIT_USAGE = 2
|
|
22
24
|
EXIT_API_ERROR = 3
|
|
23
25
|
|
|
24
26
|
_ERROR_NAMES = {
|
|
@@ -93,6 +95,26 @@ def _network_error(err: OSError) -> FigmaError:
|
|
|
93
95
|
return FigmaError({"error": "network_error", "message": str(reason)})
|
|
94
96
|
|
|
95
97
|
|
|
98
|
+
def token_path() -> Path:
|
|
99
|
+
"""Where ``figma auth login`` stores the token: ``~/.config/figma/token``."""
|
|
100
|
+
return Path.home() / ".config" / "figma" / "token"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def read_token_file() -> str:
|
|
104
|
+
"""Return the stored token, or "" when no regular file holds one."""
|
|
105
|
+
path = token_path()
|
|
106
|
+
if not path.is_file():
|
|
107
|
+
return ""
|
|
108
|
+
try:
|
|
109
|
+
return path.read_text(encoding="utf-8").strip()
|
|
110
|
+
except (OSError, UnicodeError) as err:
|
|
111
|
+
raise FigmaError({"error": "token_unreadable", "message": str(err)}) from None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def api_base() -> str:
|
|
115
|
+
return os.environ.get("FIGMA_API_BASE", "").strip() or DEFAULT_API_BASE
|
|
116
|
+
|
|
117
|
+
|
|
96
118
|
def _quote(segment: str) -> str:
|
|
97
119
|
return urllib.parse.quote(segment, safe="")
|
|
98
120
|
|
|
@@ -113,13 +135,15 @@ class FigmaClient:
|
|
|
113
135
|
|
|
114
136
|
@classmethod
|
|
115
137
|
def from_env(cls) -> "FigmaClient":
|
|
116
|
-
token
|
|
138
|
+
"""Read FIGMA_TOKEN, falling back to the stored token file."""
|
|
139
|
+
token = os.environ.get("FIGMA_TOKEN", "").strip() or read_token_file()
|
|
117
140
|
if not token:
|
|
118
|
-
|
|
119
|
-
|
|
141
|
+
message = (
|
|
142
|
+
f"FIGMA_TOKEN is not set and {token_path()} holds no token;"
|
|
143
|
+
" run 'figma auth login'"
|
|
120
144
|
)
|
|
121
|
-
|
|
122
|
-
return cls(token,
|
|
145
|
+
raise FigmaError({"error": "missing_token", "message": message})
|
|
146
|
+
return cls(token, api_base())
|
|
123
147
|
|
|
124
148
|
def request(
|
|
125
149
|
self,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""``figma auth login``: obtain a token, validate it, then store it.
|
|
2
|
+
|
|
3
|
+
The token is checked against ``GET /v1/me`` before anything touches disk, so a
|
|
4
|
+
rejected token never replaces a working one.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import getpass
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
import sys
|
|
12
|
+
import webbrowser
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from figma_cli.client import EXIT_USAGE, FigmaClient, FigmaError, api_base, token_path
|
|
16
|
+
|
|
17
|
+
SETTINGS_URL = "https://www.figma.com/settings"
|
|
18
|
+
SCOPES = (
|
|
19
|
+
"current_user:read",
|
|
20
|
+
"file_content:read",
|
|
21
|
+
"file_comments:read",
|
|
22
|
+
"file_comments:write",
|
|
23
|
+
)
|
|
24
|
+
INSTRUCTIONS = f"""\
|
|
25
|
+
Create a personal access token for figma-cli:
|
|
26
|
+
1. Open {SETTINGS_URL} and go to Security > Personal access tokens.
|
|
27
|
+
2. Generate a new token with these scopes:
|
|
28
|
+
{chr(10).join(f" {scope}" for scope in SCOPES)}
|
|
29
|
+
3. Paste the token below (input is hidden).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class TokenInputError(FigmaError):
|
|
34
|
+
"""A missing or malformed token on input: a usage error, exit 2."""
|
|
35
|
+
|
|
36
|
+
exit_code = EXIT_USAGE
|
|
37
|
+
|
|
38
|
+
def __init__(self, error: str, message: str):
|
|
39
|
+
super().__init__({"error": error, "message": message})
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _prompt(open_browser: bool) -> str:
|
|
43
|
+
print(INSTRUCTIONS, file=sys.stderr)
|
|
44
|
+
if open_browser:
|
|
45
|
+
webbrowser.open(SETTINGS_URL)
|
|
46
|
+
return getpass.getpass("Figma personal access token: ")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def candidate_token(args: argparse.Namespace) -> str:
|
|
50
|
+
"""Take the token from --token, from stdin, or from a hidden prompt."""
|
|
51
|
+
if args.token is not None and args.token != "-":
|
|
52
|
+
token = args.token
|
|
53
|
+
elif args.token == "-" or not sys.stdin.isatty():
|
|
54
|
+
token = sys.stdin.read()
|
|
55
|
+
else:
|
|
56
|
+
token = _prompt(args.browser is not False)
|
|
57
|
+
token = token.strip()
|
|
58
|
+
if not token:
|
|
59
|
+
raise TokenInputError("empty_token", "no token was provided")
|
|
60
|
+
if any(not "!" <= ch <= "~" for ch in token):
|
|
61
|
+
message = "token must be visible ASCII, without spaces or line breaks"
|
|
62
|
+
raise TokenInputError("invalid_token", message)
|
|
63
|
+
return token
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def save_token(token: str) -> Path:
|
|
67
|
+
"""Store the token at mode 0600, replacing any old file only once it is written.
|
|
68
|
+
|
|
69
|
+
The new file is created 0600 under a fresh name (O_EXCL, so never through a
|
|
70
|
+
planted file or symlink), then renamed over the old one: a failed write
|
|
71
|
+
leaves the previous token intact, and nothing is ever readable by others.
|
|
72
|
+
"""
|
|
73
|
+
path = token_path()
|
|
74
|
+
tmp = path.with_name(f".{path.name}.{secrets.token_hex(8)}")
|
|
75
|
+
try:
|
|
76
|
+
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
77
|
+
path.parent.chmod(0o700) # refuses a directory owned by someone else
|
|
78
|
+
fd = os.open(tmp, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
79
|
+
try:
|
|
80
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
81
|
+
fh.write(token + "\n")
|
|
82
|
+
fh.flush()
|
|
83
|
+
os.fsync(fh.fileno())
|
|
84
|
+
os.replace(tmp, path)
|
|
85
|
+
try:
|
|
86
|
+
dir_fd = os.open(path.parent, os.O_RDONLY)
|
|
87
|
+
try:
|
|
88
|
+
os.fsync(dir_fd)
|
|
89
|
+
finally:
|
|
90
|
+
os.close(dir_fd)
|
|
91
|
+
except OSError:
|
|
92
|
+
pass
|
|
93
|
+
except BaseException:
|
|
94
|
+
tmp.unlink(missing_ok=True)
|
|
95
|
+
raise
|
|
96
|
+
except OSError as err:
|
|
97
|
+
raise FigmaError({"error": "write_failed", "message": str(err)}) from None
|
|
98
|
+
return path
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def login(args: argparse.Namespace) -> tuple[dict, Path]:
|
|
102
|
+
"""Validate a candidate token, store it, and return (identity, path)."""
|
|
103
|
+
token = candidate_token(args)
|
|
104
|
+
me = FigmaClient(token, api_base()).me()
|
|
105
|
+
if not isinstance(me, dict) or not me.get("id"):
|
|
106
|
+
message = "GET /v1/me returned no user id"
|
|
107
|
+
raise FigmaError({"error": "invalid_response", "message": message})
|
|
108
|
+
return me, save_token(token)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
@pytest.fixture(autouse=True)
|
|
5
|
+
def home(monkeypatch, tmp_path):
|
|
6
|
+
"""Point HOME at an empty directory so no test reads a real stored token."""
|
|
7
|
+
home = tmp_path / "home"
|
|
8
|
+
home.mkdir()
|
|
9
|
+
monkeypatch.setenv("HOME", str(home))
|
|
10
|
+
monkeypatch.setenv("USERPROFILE", str(home))
|
|
11
|
+
return home
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Hermetic tests for the CLI entrypoint: no network, no installed scripts required."""
|
|
2
2
|
|
|
3
|
+
import io
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import subprocess
|
|
@@ -242,7 +243,116 @@ def test_error_human_on_stderr(stub, monkeypatch, capsys):
|
|
|
242
243
|
assert "forbidden (HTTP 403): Invalid token" in captured.err
|
|
243
244
|
|
|
244
245
|
|
|
245
|
-
def test_missing_token_exits_three(monkeypatch, capsys):
|
|
246
|
+
def test_missing_token_exits_three(home, monkeypatch, capsys):
|
|
247
|
+
monkeypatch.setenv("HOME", str(home))
|
|
246
248
|
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
247
249
|
assert main(["auth", "check", "--json"]) == 3
|
|
248
250
|
assert json.loads(capsys.readouterr().out)["error"] == "missing_token"
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# auth login: token acquisition, with a stub standing in for GET /v1/me.
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
class FakeStdin(io.StringIO):
|
|
257
|
+
def __init__(self, text: str = "", tty: bool = False):
|
|
258
|
+
super().__init__(text)
|
|
259
|
+
self.tty = tty
|
|
260
|
+
|
|
261
|
+
def isatty(self):
|
|
262
|
+
return self.tty
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
@pytest.fixture
|
|
266
|
+
def login_stub(monkeypatch):
|
|
267
|
+
"""Record the token login validates with; fail if it goes through from_env."""
|
|
268
|
+
seen = []
|
|
269
|
+
|
|
270
|
+
def build(token, base):
|
|
271
|
+
seen.append((token, base))
|
|
272
|
+
return StubClient()
|
|
273
|
+
|
|
274
|
+
def no_env():
|
|
275
|
+
raise AssertionError("auth login must not call FigmaClient.from_env")
|
|
276
|
+
|
|
277
|
+
monkeypatch.setattr("figma_cli.login.FigmaClient", build)
|
|
278
|
+
monkeypatch.setattr("figma_cli.cli.FigmaClient.from_env", no_env)
|
|
279
|
+
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
280
|
+
monkeypatch.delenv("FIGMA_API_BASE", raising=False)
|
|
281
|
+
return seen
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _stored(home: Path) -> str:
|
|
285
|
+
return (home / ".config" / "figma" / "token").read_text()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_login_token_flag(login_stub, home, monkeypatch, capsys):
|
|
289
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin("ignored"))
|
|
290
|
+
assert main(["auth", "login", "--token", " figd_flag ", "--json"]) == 0
|
|
291
|
+
assert login_stub == [("figd_flag", "https://api.figma.com")]
|
|
292
|
+
assert _stored(home) == "figd_flag\n"
|
|
293
|
+
out = json.loads(capsys.readouterr().out)
|
|
294
|
+
assert out == {
|
|
295
|
+
"id": "1",
|
|
296
|
+
"handle": "agent",
|
|
297
|
+
"email": "a@example.com",
|
|
298
|
+
"token_path": str(home / ".config" / "figma" / "token"),
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@pytest.mark.parametrize("argv", [["--token", "-"], []])
|
|
303
|
+
def test_login_reads_stdin(argv, login_stub, home, monkeypatch, capsys):
|
|
304
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin("figd_piped\n"))
|
|
305
|
+
assert main(["auth", "login", *argv]) == 0
|
|
306
|
+
assert login_stub[0][0] == "figd_piped"
|
|
307
|
+
assert _stored(home) == "figd_piped\n"
|
|
308
|
+
out = capsys.readouterr().out
|
|
309
|
+
assert "Authenticated as agent <a@example.com> (1)" in out
|
|
310
|
+
assert "Token saved to" in out
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
@pytest.mark.parametrize("argv", [["--token", "-"], [], ["--token", " "]])
|
|
314
|
+
def test_login_empty_token_exits_two(argv, login_stub, home, monkeypatch, capsys):
|
|
315
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin(" \n"))
|
|
316
|
+
assert main(["auth", "login", *argv, "--json"]) == 2
|
|
317
|
+
assert json.loads(capsys.readouterr().out)["error"] == "empty_token"
|
|
318
|
+
assert login_stub == []
|
|
319
|
+
assert not (home / ".config").exists()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@pytest.mark.parametrize("flag, opened", [([], True), (["--no-browser"], False)])
|
|
323
|
+
def test_login_interactive_prompt(flag, opened, login_stub, home, monkeypatch, capsys):
|
|
324
|
+
urls = []
|
|
325
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin(tty=True))
|
|
326
|
+
monkeypatch.setattr("getpass.getpass", lambda prompt: "figd_typed")
|
|
327
|
+
monkeypatch.setattr("webbrowser.open", urls.append)
|
|
328
|
+
assert main(["auth", "login", *flag]) == 0
|
|
329
|
+
assert urls == (["https://www.figma.com/settings"] if opened else [])
|
|
330
|
+
err = capsys.readouterr().err
|
|
331
|
+
for scope in (
|
|
332
|
+
"current_user:read",
|
|
333
|
+
"file_content:read",
|
|
334
|
+
"file_comments:read",
|
|
335
|
+
"file_comments:write",
|
|
336
|
+
):
|
|
337
|
+
assert scope in err
|
|
338
|
+
assert _stored(home) == "figd_typed\n"
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def test_login_rejected_token_writes_nothing(login_stub, home, monkeypatch, capsys):
|
|
342
|
+
payload = {"error": "forbidden", "status": 403, "message": "Invalid token"}
|
|
343
|
+
monkeypatch.setattr(StubClient, "me", _raise(payload))
|
|
344
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin("figd_bad"))
|
|
345
|
+
assert main(["auth", "login", "--json"]) == 3
|
|
346
|
+
assert json.loads(capsys.readouterr().out) == payload
|
|
347
|
+
assert not (home / ".config").exists()
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
@pytest.mark.parametrize(
|
|
351
|
+
"token", ["figd_a\nfigd_b", "figd_a b", "figd_\x00", "figd_\u20ac"]
|
|
352
|
+
)
|
|
353
|
+
def test_login_malformed_token_exits_two(token, login_stub, home, monkeypatch, capsys):
|
|
354
|
+
monkeypatch.setattr(sys, "stdin", FakeStdin(token))
|
|
355
|
+
assert main(["auth", "login", "--json"]) == 2
|
|
356
|
+
assert json.loads(capsys.readouterr().out)["error"] == "invalid_token"
|
|
357
|
+
assert login_stub == []
|
|
358
|
+
assert not (home / ".config").exists()
|
|
@@ -4,6 +4,9 @@ import email.message
|
|
|
4
4
|
import email.utils
|
|
5
5
|
import io
|
|
6
6
|
import json
|
|
7
|
+
import os
|
|
8
|
+
import stat
|
|
9
|
+
import sys
|
|
7
10
|
import threading
|
|
8
11
|
import urllib.error
|
|
9
12
|
from collections.abc import Iterator
|
|
@@ -134,7 +137,8 @@ def test_delete_comment_accepts_empty_body():
|
|
|
134
137
|
assert opener.requests[0].full_url.endswith("/v1/files/k/comments/42")
|
|
135
138
|
|
|
136
139
|
|
|
137
|
-
def test_missing_token(monkeypatch):
|
|
140
|
+
def test_missing_token(home, monkeypatch):
|
|
141
|
+
monkeypatch.setenv("HOME", str(home))
|
|
138
142
|
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
139
143
|
with pytest.raises(FigmaError) as exc:
|
|
140
144
|
FigmaClient.from_env()
|
|
@@ -241,3 +245,160 @@ def test_export_downloads_asset_without_token(monkeypatch, tmp_path, capsys):
|
|
|
241
245
|
assert [r[1] for r in cdn_rec.requests] == ["/hop/1-2.png", "/1-2.png"]
|
|
242
246
|
assert not any(_has_token(r[2]) for r in cdn_rec.requests)
|
|
243
247
|
assert not any(TOKEN in json.dumps(r) for r in cdn_rec.requests)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# Stored token: auth login against a loopback /v1/me, and the from_env fallback.
|
|
251
|
+
|
|
252
|
+
ME = {"id": "123", "handle": "test", "email": "test@example.com"}
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def _token_file(home):
|
|
256
|
+
return home / ".config" / "figma" / "token"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _seed(home, content: bytes, mode: int = 0o600):
|
|
260
|
+
path = _token_file(home)
|
|
261
|
+
path.parent.mkdir(parents=True)
|
|
262
|
+
path.write_bytes(content)
|
|
263
|
+
path.chmod(mode)
|
|
264
|
+
return path
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _login(monkeypatch, api: str, token: str, *argv: str) -> int:
|
|
268
|
+
monkeypatch.setenv("FIGMA_API_BASE", api)
|
|
269
|
+
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
270
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(token + "\n"))
|
|
271
|
+
return main(["auth", "login", "--json", *argv])
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def test_login_first_run_stores_token_0600(home, monkeypatch, capsys):
|
|
275
|
+
with serve() as (api, rec):
|
|
276
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
277
|
+
assert _login(monkeypatch, api, TOKEN) == 0
|
|
278
|
+
assert rec.requests[0][2].get(TOKEN_HEADER) == TOKEN
|
|
279
|
+
path = _token_file(home)
|
|
280
|
+
assert json.loads(capsys.readouterr().out) == {**ME, "token_path": str(path)}
|
|
281
|
+
assert path.read_text() == TOKEN + "\n"
|
|
282
|
+
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
|
|
283
|
+
assert stat.S_IMODE(os.stat(path.parent).st_mode) == 0o700
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def test_login_tightens_looser_existing_file(home, monkeypatch, capsys):
|
|
287
|
+
path = _seed(home, b"figd_old\n", mode=0o644)
|
|
288
|
+
with serve() as (api, rec):
|
|
289
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
290
|
+
assert _login(monkeypatch, api, TOKEN) == 0
|
|
291
|
+
assert path.read_text() == TOKEN + "\n"
|
|
292
|
+
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@pytest.mark.parametrize("status", [401, 403])
|
|
296
|
+
def test_login_rejected_creates_no_file(status, home, monkeypatch, capsys):
|
|
297
|
+
with serve() as (api, rec):
|
|
298
|
+
rec.routes["/v1/me"] = (status, {}, b'{"err": "Invalid token"}')
|
|
299
|
+
assert _login(monkeypatch, api, "figd_bad") == 3
|
|
300
|
+
out = json.loads(capsys.readouterr().out)
|
|
301
|
+
assert out["status"] == status
|
|
302
|
+
assert not (home / ".config").exists()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
@pytest.mark.parametrize("status", [401, 403])
|
|
306
|
+
def test_login_rejected_preserves_existing_file(status, home, monkeypatch, capsys):
|
|
307
|
+
original = b"figd_working-token\n"
|
|
308
|
+
path = _seed(home, original)
|
|
309
|
+
with serve() as (api, rec):
|
|
310
|
+
rec.routes["/v1/me"] = (status, {}, b"{}")
|
|
311
|
+
assert _login(monkeypatch, api, "figd_bad") == 3
|
|
312
|
+
assert path.read_bytes() == original
|
|
313
|
+
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def test_login_write_failure_is_structured(home, monkeypatch, capsys):
|
|
317
|
+
(home / ".config").write_text("not a directory")
|
|
318
|
+
with serve() as (api, rec):
|
|
319
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
320
|
+
assert _login(monkeypatch, api, TOKEN) == 3
|
|
321
|
+
assert json.loads(capsys.readouterr().out)["error"] == "write_failed"
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def test_check_falls_back_to_stripped_token_file(home, monkeypatch, capsys):
|
|
325
|
+
_seed(home, f" {TOKEN}\n\n".encode())
|
|
326
|
+
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
327
|
+
with serve() as (api, rec):
|
|
328
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
329
|
+
monkeypatch.setenv("FIGMA_API_BASE", api)
|
|
330
|
+
assert main(["auth", "check", "--json"]) == 0
|
|
331
|
+
assert rec.requests[0][2].get(TOKEN_HEADER) == TOKEN
|
|
332
|
+
assert json.loads(capsys.readouterr().out) == ME
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def test_env_token_overrides_token_file(home, monkeypatch):
|
|
336
|
+
_seed(home, b"figd_from_file\n")
|
|
337
|
+
monkeypatch.setenv("FIGMA_TOKEN", TOKEN)
|
|
338
|
+
assert FigmaClient.from_env().token == TOKEN
|
|
339
|
+
monkeypatch.setenv("FIGMA_TOKEN", " ")
|
|
340
|
+
assert FigmaClient.from_env().token == "figd_from_file"
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def test_token_file_directory_counts_as_missing(home, monkeypatch):
|
|
344
|
+
_token_file(home).mkdir(parents=True)
|
|
345
|
+
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
346
|
+
with pytest.raises(FigmaError) as exc:
|
|
347
|
+
FigmaClient.from_env()
|
|
348
|
+
assert exc.value.payload["error"] == "missing_token"
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def test_undecodable_token_file_is_structured(home, monkeypatch, capsys):
|
|
352
|
+
_seed(home, b"\xff\xfe\x00bad")
|
|
353
|
+
monkeypatch.delenv("FIGMA_TOKEN", raising=False)
|
|
354
|
+
assert main(["auth", "check", "--json"]) == 3
|
|
355
|
+
assert json.loads(capsys.readouterr().out)["error"] == "token_unreadable"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def test_login_replaces_symlink_without_following_it(home, monkeypatch, capsys):
|
|
359
|
+
target = home / "elsewhere"
|
|
360
|
+
target.write_text("untouched\n")
|
|
361
|
+
path = _token_file(home)
|
|
362
|
+
path.parent.mkdir(parents=True)
|
|
363
|
+
path.symlink_to(target)
|
|
364
|
+
with serve() as (api, rec):
|
|
365
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
366
|
+
assert _login(monkeypatch, api, TOKEN) == 0
|
|
367
|
+
assert target.read_text() == "untouched\n"
|
|
368
|
+
assert not path.is_symlink()
|
|
369
|
+
assert path.read_text() == TOKEN + "\n"
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def test_login_tightens_existing_directory(home, monkeypatch, capsys):
|
|
373
|
+
_token_file(home).parent.mkdir(parents=True, mode=0o755)
|
|
374
|
+
with serve() as (api, rec):
|
|
375
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
376
|
+
assert _login(monkeypatch, api, TOKEN) == 0
|
|
377
|
+
assert stat.S_IMODE(os.stat(_token_file(home).parent).st_mode) == 0o700
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def test_login_failed_write_keeps_old_token(home, monkeypatch, capsys):
|
|
381
|
+
original = b"figd_working-token\n"
|
|
382
|
+
path = _seed(home, original)
|
|
383
|
+
|
|
384
|
+
def fail(*args):
|
|
385
|
+
raise OSError("disk full")
|
|
386
|
+
|
|
387
|
+
monkeypatch.setattr("figma_cli.login.os.replace", fail)
|
|
388
|
+
with serve() as (api, rec):
|
|
389
|
+
rec.routes["/v1/me"] = (200, {}, json.dumps(ME).encode())
|
|
390
|
+
assert _login(monkeypatch, api, TOKEN) == 3
|
|
391
|
+
assert json.loads(capsys.readouterr().out)["error"] == "write_failed"
|
|
392
|
+
assert path.read_bytes() == original
|
|
393
|
+
assert sorted(p.name for p in path.parent.iterdir()) == ["token"]
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@pytest.mark.parametrize("body", [b"{}", b"[]", b'{"handle": "x"}'])
|
|
397
|
+
def test_login_without_identity_keeps_old_token(body, home, monkeypatch, capsys):
|
|
398
|
+
original = b"figd_working-token\n"
|
|
399
|
+
path = _seed(home, original)
|
|
400
|
+
with serve() as (api, rec):
|
|
401
|
+
rec.routes["/v1/me"] = (200, {}, body)
|
|
402
|
+
assert _login(monkeypatch, api, TOKEN) == 3
|
|
403
|
+
assert json.loads(capsys.readouterr().out)["error"] == "invalid_response"
|
|
404
|
+
assert path.read_bytes() == original
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
"""Live Figma API checks. Skipped unless FIGMA_TOKEN
|
|
1
|
+
"""Live Figma API checks. Skipped unless FIGMA_TOKEN is set; the file, export and
|
|
2
|
+
comment checks also need FIGMA_TEST_FILE_KEY.
|
|
2
3
|
|
|
3
4
|
FIGMA_TEST_NODE_ID (optional) names a frame to export. The comment test posts one
|
|
4
5
|
comment and deletes it again.
|
|
5
6
|
"""
|
|
6
7
|
|
|
8
|
+
import io
|
|
7
9
|
import json
|
|
8
10
|
import os
|
|
11
|
+
import stat
|
|
12
|
+
import sys
|
|
9
13
|
import time
|
|
10
14
|
|
|
11
15
|
import pytest
|
|
@@ -16,9 +20,8 @@ TOKEN = os.environ.get("FIGMA_TOKEN", "").strip()
|
|
|
16
20
|
FILE_KEY = os.environ.get("FIGMA_TEST_FILE_KEY", "").strip()
|
|
17
21
|
NODE_ID = os.environ.get("FIGMA_TEST_NODE_ID", "").strip()
|
|
18
22
|
|
|
19
|
-
pytestmark = pytest.mark.skipif(
|
|
20
|
-
|
|
21
|
-
)
|
|
23
|
+
pytestmark = pytest.mark.skipif(not TOKEN, reason="FIGMA_TOKEN not set")
|
|
24
|
+
needs_file = pytest.mark.skipif(not FILE_KEY, reason="FIGMA_TEST_FILE_KEY not set")
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
def _run(capsys, *argv):
|
|
@@ -41,6 +44,20 @@ def test_bogus_token_is_structured(monkeypatch, capsys):
|
|
|
41
44
|
assert out["status"] in (401, 403)
|
|
42
45
|
|
|
43
46
|
|
|
47
|
+
def test_login_then_check_from_token_file(home, monkeypatch, capsys):
|
|
48
|
+
monkeypatch.delenv("FIGMA_TOKEN")
|
|
49
|
+
monkeypatch.setattr(sys, "stdin", io.StringIO(TOKEN + "\n"))
|
|
50
|
+
code, out = _run(capsys, "auth", "login")
|
|
51
|
+
assert code == 0, out
|
|
52
|
+
path = home / ".config" / "figma" / "token"
|
|
53
|
+
assert out["token_path"] == str(path)
|
|
54
|
+
assert stat.S_IMODE(os.stat(path).st_mode) == 0o600
|
|
55
|
+
code, checked = _run(capsys, "auth", "check")
|
|
56
|
+
assert code == 0, checked
|
|
57
|
+
assert checked == {key: out[key] for key in ("id", "handle", "email")}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@needs_file
|
|
44
61
|
def test_file_get_depth_one(capsys):
|
|
45
62
|
code, out = _run(capsys, "file", "get", FILE_KEY, "--depth", "1")
|
|
46
63
|
assert code == 0, out
|
|
@@ -49,6 +66,7 @@ def test_file_get_depth_one(capsys):
|
|
|
49
66
|
assert all("children" not in page for page in pages)
|
|
50
67
|
|
|
51
68
|
|
|
69
|
+
@needs_file
|
|
52
70
|
@pytest.mark.skipif(not NODE_ID, reason="FIGMA_TEST_NODE_ID not set")
|
|
53
71
|
def test_export_png(tmp_path, capsys):
|
|
54
72
|
argv = ("export", FILE_KEY, "--nodes", NODE_ID, "--output", str(tmp_path))
|
|
@@ -58,6 +76,7 @@ def test_export_png(tmp_path, capsys):
|
|
|
58
76
|
assert fh.read(4) == b"\x89PNG"
|
|
59
77
|
|
|
60
78
|
|
|
79
|
+
@needs_file
|
|
61
80
|
def test_comment_round_trip(capsys):
|
|
62
81
|
code, posted = _run(capsys, "comment", "post", FILE_KEY, "--message", "test")
|
|
63
82
|
assert code == 0, posted
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|