bunq-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.
- bunq_cli/__init__.py +0 -0
- bunq_cli/client.py +845 -0
- bunq_cli/commands/__init__.py +0 -0
- bunq_cli/commands/accounts.py +37 -0
- bunq_cli/commands/bunqme.py +75 -0
- bunq_cli/commands/callbacks.py +122 -0
- bunq_cli/commands/cards.py +122 -0
- bunq_cli/commands/drafts.py +79 -0
- bunq_cli/commands/events.py +37 -0
- bunq_cli/commands/login.py +131 -0
- bunq_cli/commands/oauth_client.py +113 -0
- bunq_cli/commands/payments.py +90 -0
- bunq_cli/commands/profiles.py +91 -0
- bunq_cli/commands/requests.py +122 -0
- bunq_cli/commands/sandbox.py +25 -0
- bunq_cli/commands/scheduled.py +108 -0
- bunq_cli/commands/statements.py +97 -0
- bunq_cli/config.py +180 -0
- bunq_cli/main.py +210 -0
- bunq_cli/oauth.py +176 -0
- bunq_cli/output.py +65 -0
- bunq_cli-0.2.0.dist-info/METADATA +68 -0
- bunq_cli-0.2.0.dist-info/RECORD +27 -0
- bunq_cli-0.2.0.dist-info/WHEEL +5 -0
- bunq_cli-0.2.0.dist-info/entry_points.txt +2 -0
- bunq_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- bunq_cli-0.2.0.dist-info/top_level.txt +1 -0
bunq_cli/config.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""Configuration management for Bunq CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import click
|
|
11
|
+
|
|
12
|
+
if sys.version_info >= (3, 11):
|
|
13
|
+
import tomllib
|
|
14
|
+
else:
|
|
15
|
+
try:
|
|
16
|
+
import tomllib
|
|
17
|
+
except ModuleNotFoundError:
|
|
18
|
+
import tomli as tomllib # type: ignore[no-redef]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
DEFAULT_CONFIG_PATH = Path.home() / ".config" / "bunq-cli.toml"
|
|
22
|
+
DEFAULT_CONTEXT_PATH = Path.home() / ".config" / "bunq-cli.conf"
|
|
23
|
+
CONTEXTS_DIR = Path.home() / ".config" / "bunq-cli" / "contexts"
|
|
24
|
+
PROFILE_NAME_RE = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _serialize_toml(data: dict, prefix: str = "") -> str:
|
|
28
|
+
"""Serialize a dict to TOML format (minimal implementation)."""
|
|
29
|
+
lines: list[str] = []
|
|
30
|
+
for key, value in data.items():
|
|
31
|
+
if not isinstance(value, dict):
|
|
32
|
+
lines.append(f"{key} = {_toml_value(value)}")
|
|
33
|
+
for key, value in data.items():
|
|
34
|
+
if isinstance(value, dict):
|
|
35
|
+
full_key = f"{prefix}.{key}" if prefix else key
|
|
36
|
+
has_subtables = any(isinstance(v, dict) for v in value.values())
|
|
37
|
+
if has_subtables:
|
|
38
|
+
scalars = {k: v for k, v in value.items() if not isinstance(v, dict)}
|
|
39
|
+
if scalars:
|
|
40
|
+
lines.append("")
|
|
41
|
+
lines.append(f"[{full_key}]")
|
|
42
|
+
for k, v in scalars.items():
|
|
43
|
+
lines.append(f"{k} = {_toml_value(v)}")
|
|
44
|
+
subtables = {k: v for k, v in value.items() if isinstance(v, dict)}
|
|
45
|
+
lines.append(_serialize_toml(subtables, prefix=full_key))
|
|
46
|
+
else:
|
|
47
|
+
lines.append("")
|
|
48
|
+
lines.append(f"[{full_key}]")
|
|
49
|
+
for k, v in value.items():
|
|
50
|
+
lines.append(f"{k} = {_toml_value(v)}")
|
|
51
|
+
return "\n".join(lines)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _toml_value(value: object) -> str:
|
|
55
|
+
if isinstance(value, str):
|
|
56
|
+
# JSON string escapes are valid TOML basic-string escapes.
|
|
57
|
+
return json.dumps(value)
|
|
58
|
+
if isinstance(value, bool):
|
|
59
|
+
return "true" if value else "false"
|
|
60
|
+
if isinstance(value, int):
|
|
61
|
+
return str(value)
|
|
62
|
+
raise TypeError(f"Unsupported TOML value type: {type(value)}")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def load_config(path: Path | None = None) -> dict:
|
|
66
|
+
"""Load configuration from TOML file."""
|
|
67
|
+
config_path = path or DEFAULT_CONFIG_PATH
|
|
68
|
+
if not config_path.exists():
|
|
69
|
+
return {}
|
|
70
|
+
with open(config_path, "rb") as f:
|
|
71
|
+
return _migrate(tomllib.load(f))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _migrate(config: dict) -> dict:
|
|
75
|
+
"""Turn the pre-profiles [credentials] section into a 'default' profile."""
|
|
76
|
+
creds = config.pop("credentials", None)
|
|
77
|
+
if creds and "profiles" not in config:
|
|
78
|
+
config["profiles"] = {
|
|
79
|
+
"default": {
|
|
80
|
+
"context_path": creds.get("context_path", str(DEFAULT_CONTEXT_PATH)),
|
|
81
|
+
"sandbox": creds.get("sandbox", False),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
config.setdefault("default_profile", "default")
|
|
85
|
+
return config
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def restrict_permissions(path: Path) -> None:
|
|
89
|
+
"""Best effort: create/chmod a credential file so only the owner can read it."""
|
|
90
|
+
try:
|
|
91
|
+
path.touch(mode=0o600, exist_ok=True)
|
|
92
|
+
path.chmod(0o600)
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def save_config(data: dict, path: Path | None = None) -> None:
|
|
98
|
+
"""Save configuration to TOML file."""
|
|
99
|
+
config_path = path or DEFAULT_CONFIG_PATH
|
|
100
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
restrict_permissions(config_path)
|
|
102
|
+
config_path.write_text(_serialize_toml(data) + "\n")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def validate_profile_name(name: str) -> str:
|
|
106
|
+
if not PROFILE_NAME_RE.match(name):
|
|
107
|
+
raise click.BadParameter(
|
|
108
|
+
f"Invalid profile name {name!r}: use letters, digits, '-' and '_'."
|
|
109
|
+
)
|
|
110
|
+
return name
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def profile_label(name: str, profile: dict) -> str:
|
|
114
|
+
display_name = profile.get("display_name")
|
|
115
|
+
return f"{display_name} ({name})" if display_name else name
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_profile(config: dict, name: str | None = None) -> tuple[str, dict]:
|
|
119
|
+
"""Return (name, profile) for the requested, default or only profile."""
|
|
120
|
+
profiles = config.get("profiles", {})
|
|
121
|
+
if not profiles:
|
|
122
|
+
raise click.ClickException("Not logged in. Run 'bunq login' first.")
|
|
123
|
+
if name is None:
|
|
124
|
+
name = config.get("default_profile")
|
|
125
|
+
if name is None and len(profiles) == 1:
|
|
126
|
+
name = next(iter(profiles))
|
|
127
|
+
if name is None:
|
|
128
|
+
raise click.ClickException(
|
|
129
|
+
"Multiple profiles configured. Pass --profile NAME or run 'bunq profiles use NAME'."
|
|
130
|
+
)
|
|
131
|
+
if name not in profiles:
|
|
132
|
+
raise click.ClickException(
|
|
133
|
+
f"Unknown profile '{name}'. Run 'bunq profiles list' to see the available profiles."
|
|
134
|
+
)
|
|
135
|
+
return name, profiles[name]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def set_profile(config: dict, name: str, **fields: object) -> dict:
|
|
139
|
+
"""Create or update a profile; fields set to None are left unchanged."""
|
|
140
|
+
profile = config.setdefault("profiles", {}).setdefault(name, {})
|
|
141
|
+
profile.update({k: v for k, v in fields.items() if v is not None})
|
|
142
|
+
config.setdefault("default_profile", name)
|
|
143
|
+
return config
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def remove_profile(config: dict, name: str) -> dict:
|
|
147
|
+
config.get("profiles", {}).pop(name, None)
|
|
148
|
+
if not config.get("profiles"):
|
|
149
|
+
config.pop("profiles", None)
|
|
150
|
+
if config.get("default_profile") == name:
|
|
151
|
+
config.pop("default_profile")
|
|
152
|
+
return config
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def get_oauth_client(config: dict) -> dict:
|
|
156
|
+
"""Get stored OAuth client credentials."""
|
|
157
|
+
return config.get("oauth", {})
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def set_oauth_client(
|
|
161
|
+
config: dict,
|
|
162
|
+
client_id: str,
|
|
163
|
+
client_secret: str,
|
|
164
|
+
oauth_client_id: int,
|
|
165
|
+
profile: str,
|
|
166
|
+
) -> dict:
|
|
167
|
+
"""Set OAuth client credentials in config."""
|
|
168
|
+
config["oauth"] = {
|
|
169
|
+
"client_id": client_id,
|
|
170
|
+
"client_secret": client_secret,
|
|
171
|
+
"oauth_client_id": oauth_client_id,
|
|
172
|
+
"profile": profile,
|
|
173
|
+
}
|
|
174
|
+
return config
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def remove_oauth_client(config: dict) -> dict:
|
|
178
|
+
"""Remove stored OAuth client credentials."""
|
|
179
|
+
config.pop("oauth", None)
|
|
180
|
+
return config
|
bunq_cli/main.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Bunq CLI - main entry point and global options."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from click.core import ParameterSource
|
|
9
|
+
|
|
10
|
+
from bunq_cli.client import BunqClient
|
|
11
|
+
from bunq_cli.config import (
|
|
12
|
+
DEFAULT_CONFIG_PATH,
|
|
13
|
+
load_config,
|
|
14
|
+
profile_label,
|
|
15
|
+
resolve_profile,
|
|
16
|
+
)
|
|
17
|
+
from bunq_cli.output import format_output
|
|
18
|
+
|
|
19
|
+
# Commands that manage profiles/credentials themselves and make no sense per profile.
|
|
20
|
+
SINGLE_PROFILE_COMMANDS = {"login", "logout", "profiles", "sandbox", "oauth-client"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Context:
|
|
24
|
+
"""Shared context for all commands."""
|
|
25
|
+
|
|
26
|
+
def __init__(self) -> None:
|
|
27
|
+
self.verbose: bool = False
|
|
28
|
+
self.quiet: bool = False
|
|
29
|
+
self.output_format: str = "auto"
|
|
30
|
+
self.no_color: bool = False
|
|
31
|
+
self.sandbox: bool = False
|
|
32
|
+
self.config_path: Path = DEFAULT_CONFIG_PATH
|
|
33
|
+
self.token: str | None = None
|
|
34
|
+
self.profile: str | None = None
|
|
35
|
+
self.all_profiles: bool = False
|
|
36
|
+
self.profile_name: str | None = None
|
|
37
|
+
self.profile_label: str = ""
|
|
38
|
+
# Set when running one profile of an --all-profiles invocation.
|
|
39
|
+
self.loop_profile: str | None = None
|
|
40
|
+
self.collected: list[tuple[object, list[str] | None]] | None = None
|
|
41
|
+
|
|
42
|
+
def get_client(self, write: bool = False, profile: str | None = None) -> BunqClient:
|
|
43
|
+
"""Build a BunqClient for the selected profile.
|
|
44
|
+
|
|
45
|
+
``write`` marks commands that change data: they are refused for
|
|
46
|
+
read-only profiles and with --all-profiles.
|
|
47
|
+
"""
|
|
48
|
+
if write and self.all_profiles:
|
|
49
|
+
raise click.UsageError("This command changes data and can't be used with --all-profiles.")
|
|
50
|
+
name, settings = resolve_profile(load_config(self.config_path), profile or self.profile)
|
|
51
|
+
if write and settings.get("read_only"):
|
|
52
|
+
raise click.ClickException(f"Profile '{name}' is read-only.")
|
|
53
|
+
self.profile_name = name
|
|
54
|
+
self.profile_label = profile_label(name, settings)
|
|
55
|
+
if self.verbose:
|
|
56
|
+
click.echo(f"Using profile {self.profile_label}", err=True)
|
|
57
|
+
|
|
58
|
+
return BunqClient(
|
|
59
|
+
context_path=Path(settings["context_path"]).expanduser(),
|
|
60
|
+
sandbox=self.sandbox or settings.get("sandbox", False),
|
|
61
|
+
verbose=self.verbose,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def confirm(self, message: str, yes: bool) -> None:
|
|
65
|
+
"""Ask for confirmation, naming the profile so clients aren't mixed up."""
|
|
66
|
+
if not yes:
|
|
67
|
+
click.confirm(f"[{self.profile_label}] {message}", abort=True)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
pass_context = click.make_pass_decorator(Context, ensure=True)
|
|
71
|
+
yes_option = click.option("--yes", "-y", is_flag=True, help="Don't ask for confirmation.")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class _CliGroup(click.Group):
|
|
75
|
+
def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
|
|
76
|
+
ctx.meta["bunq.argv"] = list(args)
|
|
77
|
+
return super().parse_args(ctx, args)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _run_for_all_profiles(ctx: Context) -> None:
|
|
81
|
+
"""Re-run the command line once per profile and merge the output."""
|
|
82
|
+
click_ctx = click.get_current_context()
|
|
83
|
+
if click_ctx.invoked_subcommand in SINGLE_PROFILE_COMMANDS:
|
|
84
|
+
raise click.UsageError(f"'{click_ctx.invoked_subcommand}' can't be used with --all-profiles.")
|
|
85
|
+
profiles = load_config(ctx.config_path).get("profiles", {})
|
|
86
|
+
if not profiles:
|
|
87
|
+
raise click.ClickException("Not logged in. Run 'bunq login' first.")
|
|
88
|
+
|
|
89
|
+
rows: list[dict] = []
|
|
90
|
+
columns: list[str] | None = None
|
|
91
|
+
failed = False
|
|
92
|
+
for name, settings in profiles.items():
|
|
93
|
+
if not ctx.quiet:
|
|
94
|
+
click.echo(f"== {profile_label(name, settings)} ==", err=True)
|
|
95
|
+
sub = Context()
|
|
96
|
+
sub.loop_profile = name
|
|
97
|
+
sub.collected = []
|
|
98
|
+
try:
|
|
99
|
+
cli.main(
|
|
100
|
+
args=click_ctx.meta["bunq.argv"],
|
|
101
|
+
prog_name=click_ctx.info_name,
|
|
102
|
+
standalone_mode=False,
|
|
103
|
+
obj=sub,
|
|
104
|
+
)
|
|
105
|
+
except (click.UsageError, click.Abort):
|
|
106
|
+
raise
|
|
107
|
+
except Exception as exc: # one client's failure shouldn't stop the others
|
|
108
|
+
failed = True
|
|
109
|
+
message = exc.format_message() if isinstance(exc, click.ClickException) else str(exc)
|
|
110
|
+
click.echo(f"Error ({name}): {message}", err=True)
|
|
111
|
+
continue
|
|
112
|
+
for data, cols in sub.collected:
|
|
113
|
+
records = data if isinstance(data, list) else [data]
|
|
114
|
+
rows += [{"profile": name, **record} for record in records]
|
|
115
|
+
columns = columns or cols
|
|
116
|
+
|
|
117
|
+
if rows:
|
|
118
|
+
click.echo(
|
|
119
|
+
format_output(rows, fmt=ctx.output_format, columns=["profile", *columns] if columns else None)
|
|
120
|
+
)
|
|
121
|
+
click_ctx.exit(1 if failed else 0)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@click.group(cls=_CliGroup)
|
|
125
|
+
@click.version_option(package_name="bunq-cli", prog_name="bunq")
|
|
126
|
+
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.")
|
|
127
|
+
@click.option("-q", "--quiet", is_flag=True, help="Suppress all output except errors.")
|
|
128
|
+
@click.option("--token", metavar="TOKEN", help="API token (overrides stored credentials).")
|
|
129
|
+
@click.option(
|
|
130
|
+
"--output",
|
|
131
|
+
"output_format",
|
|
132
|
+
type=click.Choice(["auto", "json", "table"]),
|
|
133
|
+
default="auto",
|
|
134
|
+
help="Output format.",
|
|
135
|
+
)
|
|
136
|
+
@click.option("--no-color", is_flag=True, help="Disable colored output.")
|
|
137
|
+
@click.option(
|
|
138
|
+
"--config",
|
|
139
|
+
"config_path",
|
|
140
|
+
type=click.Path(path_type=Path),
|
|
141
|
+
default=None,
|
|
142
|
+
help="Custom config file path.",
|
|
143
|
+
)
|
|
144
|
+
@click.option("--sandbox", is_flag=True, help="Use the sandbox environment.")
|
|
145
|
+
@click.option("-p", "--profile", envvar="BUNQ_PROFILE", default=None, help="Profile (client) to use. Env: BUNQ_PROFILE.")
|
|
146
|
+
@click.option("--all-profiles", is_flag=True, help="Run a read-only command for every profile.")
|
|
147
|
+
@pass_context
|
|
148
|
+
def cli(
|
|
149
|
+
ctx: Context,
|
|
150
|
+
verbose: bool,
|
|
151
|
+
quiet: bool,
|
|
152
|
+
token: str | None,
|
|
153
|
+
output_format: str,
|
|
154
|
+
no_color: bool,
|
|
155
|
+
config_path: Path | None,
|
|
156
|
+
sandbox: bool,
|
|
157
|
+
profile: str | None,
|
|
158
|
+
all_profiles: bool,
|
|
159
|
+
) -> None:
|
|
160
|
+
"""Bunq CLI - interact with the Bunq API from your terminal."""
|
|
161
|
+
ctx.verbose = verbose
|
|
162
|
+
ctx.quiet = quiet
|
|
163
|
+
ctx.output_format = output_format
|
|
164
|
+
ctx.no_color = no_color
|
|
165
|
+
ctx.sandbox = sandbox
|
|
166
|
+
ctx.token = token
|
|
167
|
+
ctx.profile = ctx.loop_profile or profile
|
|
168
|
+
ctx.all_profiles = all_profiles
|
|
169
|
+
if config_path:
|
|
170
|
+
ctx.config_path = config_path
|
|
171
|
+
if all_profiles:
|
|
172
|
+
source = click.get_current_context().get_parameter_source("profile")
|
|
173
|
+
if source == ParameterSource.COMMANDLINE:
|
|
174
|
+
raise click.UsageError("Use either --profile or --all-profiles, not both.")
|
|
175
|
+
ctx.profile = ctx.loop_profile
|
|
176
|
+
if all_profiles and ctx.loop_profile is None:
|
|
177
|
+
_run_for_all_profiles(ctx)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# Register commands
|
|
181
|
+
from bunq_cli.commands.login import login, logout # noqa: E402
|
|
182
|
+
from bunq_cli.commands.accounts import accounts # noqa: E402
|
|
183
|
+
from bunq_cli.commands.payments import payments # noqa: E402
|
|
184
|
+
from bunq_cli.commands.requests import requests # noqa: E402
|
|
185
|
+
from bunq_cli.commands.cards import cards # noqa: E402
|
|
186
|
+
from bunq_cli.commands.callbacks import callbacks # noqa: E402
|
|
187
|
+
from bunq_cli.commands.oauth_client import oauth_client # noqa: E402
|
|
188
|
+
from bunq_cli.commands.events import events # noqa: E402
|
|
189
|
+
from bunq_cli.commands.statements import statements # noqa: E402
|
|
190
|
+
from bunq_cli.commands.scheduled import scheduled # noqa: E402
|
|
191
|
+
from bunq_cli.commands.drafts import drafts # noqa: E402
|
|
192
|
+
from bunq_cli.commands.bunqme import bunqme # noqa: E402
|
|
193
|
+
from bunq_cli.commands.sandbox import sandbox # noqa: E402
|
|
194
|
+
from bunq_cli.commands.profiles import profiles # noqa: E402
|
|
195
|
+
|
|
196
|
+
cli.add_command(login)
|
|
197
|
+
cli.add_command(logout)
|
|
198
|
+
cli.add_command(accounts)
|
|
199
|
+
cli.add_command(payments)
|
|
200
|
+
cli.add_command(requests)
|
|
201
|
+
cli.add_command(cards)
|
|
202
|
+
cli.add_command(callbacks)
|
|
203
|
+
cli.add_command(oauth_client)
|
|
204
|
+
cli.add_command(events)
|
|
205
|
+
cli.add_command(statements)
|
|
206
|
+
cli.add_command(scheduled)
|
|
207
|
+
cli.add_command(drafts)
|
|
208
|
+
cli.add_command(bunqme)
|
|
209
|
+
cli.add_command(sandbox)
|
|
210
|
+
cli.add_command(profiles)
|
bunq_cli/oauth.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""OAuth authorization code flow for Bunq CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import http.server
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import threading
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import webbrowser
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import click
|
|
14
|
+
import requests
|
|
15
|
+
|
|
16
|
+
AUTH_URL_PRODUCTION = "https://oauth.bunq.com/auth"
|
|
17
|
+
AUTH_URL_SANDBOX = "https://oauth.sandbox.bunq.com/auth"
|
|
18
|
+
TOKEN_URL_PRODUCTION = "https://api.oauth.bunq.com/v1/token"
|
|
19
|
+
TOKEN_URL_SANDBOX = "https://api-oauth.sandbox.bunq.com/v1/token"
|
|
20
|
+
|
|
21
|
+
REDIRECT_HOST = "127.0.0.1"
|
|
22
|
+
REDIRECT_PORT = 12345
|
|
23
|
+
REDIRECT_URI = f"http://{REDIRECT_HOST}:{REDIRECT_PORT}/callback"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class OAuthCredentials:
|
|
28
|
+
client_id: str
|
|
29
|
+
client_secret: str
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def get_oauth_credentials(config: dict | None = None) -> OAuthCredentials | None:
|
|
33
|
+
"""Get OAuth client credentials from environment or config.
|
|
34
|
+
|
|
35
|
+
Priority: environment variables > config file.
|
|
36
|
+
"""
|
|
37
|
+
client_id = os.environ.get("BUNQ_OAUTH_CLIENT_ID")
|
|
38
|
+
client_secret = os.environ.get("BUNQ_OAUTH_CLIENT_SECRET")
|
|
39
|
+
if client_id and client_secret:
|
|
40
|
+
return OAuthCredentials(client_id=client_id, client_secret=client_secret)
|
|
41
|
+
|
|
42
|
+
if config:
|
|
43
|
+
oauth = config.get("oauth", {})
|
|
44
|
+
client_id = oauth.get("client_id")
|
|
45
|
+
client_secret = oauth.get("client_secret")
|
|
46
|
+
if client_id and client_secret:
|
|
47
|
+
return OAuthCredentials(client_id=client_id, client_secret=client_secret)
|
|
48
|
+
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def oauth_login(sandbox: bool = False, config: dict | None = None) -> str:
|
|
53
|
+
"""Run the full OAuth browser flow and return an API token.
|
|
54
|
+
|
|
55
|
+
Raises click.ClickException on failure.
|
|
56
|
+
"""
|
|
57
|
+
creds = get_oauth_credentials(config)
|
|
58
|
+
if creds is None:
|
|
59
|
+
raise click.ClickException(
|
|
60
|
+
"OAuth client not configured.\n"
|
|
61
|
+
"Run 'bunq oauth-client register' first,\n"
|
|
62
|
+
"or use 'bunq login --token YOUR_API_KEY' for direct API key authentication."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
state = secrets.token_urlsafe(32)
|
|
66
|
+
auth_code: str | None = None
|
|
67
|
+
error: str | None = None
|
|
68
|
+
server_ready = threading.Event()
|
|
69
|
+
|
|
70
|
+
class CallbackHandler(http.server.BaseHTTPRequestHandler):
|
|
71
|
+
def do_GET(self) -> None:
|
|
72
|
+
nonlocal auth_code, error
|
|
73
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
74
|
+
params = urllib.parse.parse_qs(parsed.query)
|
|
75
|
+
|
|
76
|
+
if parsed.path != "/callback":
|
|
77
|
+
self.send_response(404)
|
|
78
|
+
self.end_headers()
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
received_state = params.get("state", [None])[0]
|
|
82
|
+
if received_state != state:
|
|
83
|
+
error = "State mismatch -- possible CSRF attack."
|
|
84
|
+
self._respond("Authorization failed: state mismatch.")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if "error" in params:
|
|
88
|
+
error = params["error"][0]
|
|
89
|
+
desc = params.get("error_description", [""])[0]
|
|
90
|
+
self._respond(f"Authorization failed: {error}. {desc}")
|
|
91
|
+
return
|
|
92
|
+
|
|
93
|
+
code = params.get("code", [None])[0]
|
|
94
|
+
if code is None:
|
|
95
|
+
error = "No authorization code received."
|
|
96
|
+
self._respond("Authorization failed: no code received.")
|
|
97
|
+
return
|
|
98
|
+
|
|
99
|
+
auth_code = code
|
|
100
|
+
self._respond(
|
|
101
|
+
"Authorization successful! You can close this tab and return to your terminal."
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
def _respond(self, message: str) -> None:
|
|
105
|
+
self.send_response(200)
|
|
106
|
+
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
107
|
+
self.end_headers()
|
|
108
|
+
body = (
|
|
109
|
+
"<!DOCTYPE html><html><head>"
|
|
110
|
+
"<meta charset='utf-8'>"
|
|
111
|
+
"<style>body{font-family:system-ui;max-width:480px;margin:80px auto;text-align:center}</style>"
|
|
112
|
+
"</head><body>"
|
|
113
|
+
f"<h2>{message}</h2>"
|
|
114
|
+
"</body></html>"
|
|
115
|
+
)
|
|
116
|
+
self.wfile.write(body.encode())
|
|
117
|
+
|
|
118
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
119
|
+
pass # suppress request logs
|
|
120
|
+
|
|
121
|
+
server = http.server.HTTPServer((REDIRECT_HOST, REDIRECT_PORT), CallbackHandler)
|
|
122
|
+
|
|
123
|
+
def run_server() -> None:
|
|
124
|
+
server_ready.set()
|
|
125
|
+
server.handle_request() # handle exactly one request
|
|
126
|
+
|
|
127
|
+
thread = threading.Thread(target=run_server, daemon=True)
|
|
128
|
+
thread.start()
|
|
129
|
+
server_ready.wait()
|
|
130
|
+
|
|
131
|
+
# Build authorization URL
|
|
132
|
+
auth_base = AUTH_URL_SANDBOX if sandbox else AUTH_URL_PRODUCTION
|
|
133
|
+
auth_params = urllib.parse.urlencode({
|
|
134
|
+
"response_type": "code",
|
|
135
|
+
"client_id": creds.client_id,
|
|
136
|
+
"redirect_uri": REDIRECT_URI,
|
|
137
|
+
"state": state,
|
|
138
|
+
})
|
|
139
|
+
auth_url = f"{auth_base}?{auth_params}"
|
|
140
|
+
|
|
141
|
+
click.echo(f"Opening browser for authorization...")
|
|
142
|
+
click.echo(f"If the browser doesn't open, visit: {auth_url}")
|
|
143
|
+
webbrowser.open(auth_url)
|
|
144
|
+
|
|
145
|
+
# Wait for the callback
|
|
146
|
+
thread.join(timeout=300) # 5 minute timeout
|
|
147
|
+
server.server_close()
|
|
148
|
+
|
|
149
|
+
if auth_code is None:
|
|
150
|
+
raise click.ClickException(error or "Authorization timed out.")
|
|
151
|
+
|
|
152
|
+
# Exchange authorization code for access token
|
|
153
|
+
token_url = TOKEN_URL_SANDBOX if sandbox else TOKEN_URL_PRODUCTION
|
|
154
|
+
token_params = {
|
|
155
|
+
"grant_type": "authorization_code",
|
|
156
|
+
"code": auth_code,
|
|
157
|
+
"redirect_uri": REDIRECT_URI,
|
|
158
|
+
"client_id": creds.client_id,
|
|
159
|
+
"client_secret": creds.client_secret,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
# bunq only accepts these as query parameters, not as a form body.
|
|
163
|
+
response = requests.post(token_url, params=token_params, timeout=30)
|
|
164
|
+
if response.status_code != 200:
|
|
165
|
+
try:
|
|
166
|
+
detail = response.json()
|
|
167
|
+
except ValueError:
|
|
168
|
+
detail = response.text
|
|
169
|
+
raise click.ClickException(f"Token exchange failed ({response.status_code}): {detail}")
|
|
170
|
+
|
|
171
|
+
data = response.json()
|
|
172
|
+
access_token = data.get("access_token")
|
|
173
|
+
if not access_token:
|
|
174
|
+
raise click.ClickException(f"No access token in response: {data}")
|
|
175
|
+
|
|
176
|
+
return access_token
|
bunq_cli/output.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Output formatting for Bunq CLI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from tabulate import tabulate
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def format_output(
|
|
14
|
+
data: Any,
|
|
15
|
+
fmt: str = "auto",
|
|
16
|
+
columns: list[str] | None = None,
|
|
17
|
+
) -> str:
|
|
18
|
+
"""Format data for output.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
data: The data to format (dict or list of dicts).
|
|
22
|
+
fmt: Output format - "json", "table", or "auto".
|
|
23
|
+
columns: Column names to include in table output. If None, uses all keys.
|
|
24
|
+
"""
|
|
25
|
+
if fmt == "auto":
|
|
26
|
+
fmt = "table" if sys.stdout.isatty() else "json"
|
|
27
|
+
|
|
28
|
+
if fmt == "json":
|
|
29
|
+
return json.dumps(data, indent=2, default=str)
|
|
30
|
+
|
|
31
|
+
if isinstance(data, dict):
|
|
32
|
+
rows = [[k, v] for k, v in data.items() if columns is None or k in columns]
|
|
33
|
+
return tabulate(rows, headers=["Field", "Value"], tablefmt="simple")
|
|
34
|
+
|
|
35
|
+
if isinstance(data, list) and data:
|
|
36
|
+
if columns is None:
|
|
37
|
+
columns = list(data[0].keys())
|
|
38
|
+
rows = [[record.get(col, "") for col in columns] for record in data]
|
|
39
|
+
return tabulate(rows, headers=columns, tablefmt="simple")
|
|
40
|
+
|
|
41
|
+
return str(data)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def echo_output(
|
|
45
|
+
data: Any,
|
|
46
|
+
fmt: str = "auto",
|
|
47
|
+
columns: list[str] | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Format and print data (or hand it to --all-profiles for merging)."""
|
|
50
|
+
if data is None:
|
|
51
|
+
return
|
|
52
|
+
collected = _collected()
|
|
53
|
+
if collected is not None:
|
|
54
|
+
collected.append((data, columns))
|
|
55
|
+
return
|
|
56
|
+
output = format_output(data, fmt=fmt, columns=columns)
|
|
57
|
+
click.echo(output)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _collected() -> list | None:
|
|
61
|
+
from bunq_cli.main import Context
|
|
62
|
+
|
|
63
|
+
click_ctx = click.get_current_context(silent=True)
|
|
64
|
+
obj = click_ctx.find_object(Context) if click_ctx else None
|
|
65
|
+
return getattr(obj, "collected", None)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: bunq-cli
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Command-line interface for the Bunq API
|
|
5
|
+
Author-email: Koert van der Veer <koert@ondergetekende.nl>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/ondergetekende/bunq-cli
|
|
8
|
+
Project-URL: Repository, https://github.com/ondergetekende/bunq-cli
|
|
9
|
+
Project-URL: Issues, https://github.com/ondergetekende/bunq-cli/issues
|
|
10
|
+
Keywords: bunq,banking,cli,payments,finance
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
14
|
+
Classifier: Intended Audience :: System Administrators
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Office/Business :: Financial
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: click>=8.1
|
|
24
|
+
Requires-Dist: bunq-sdk>=1.28
|
|
25
|
+
Requires-Dist: requests>=2.24
|
|
26
|
+
Requires-Dist: tabulate>=0.9
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
29
|
+
Dynamic: license-file
|
|
30
|
+
|
|
31
|
+
# Bunq CLI
|
|
32
|
+
|
|
33
|
+
A command-line interface for [Bunq](https://www.bunq.com/), the online bank. Manage accounts, payments, cards, and more from your terminal.
|
|
34
|
+
|
|
35
|
+
> **Disclaimer:** This project is not affiliated with, endorsed by, or in any way associated with Bunq B.V. "Bunq" is a trademark of Bunq N.V. This is an independent, open-source tool that uses the public Bunq API.
|
|
36
|
+
|
|
37
|
+
## Quick start
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install bunq-cli
|
|
41
|
+
bunq login
|
|
42
|
+
bunq accounts list
|
|
43
|
+
bunq payments list --account "My Account"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
See [Getting Started](docs/getting-started.md) for full setup instructions, output formats, pagination, and global options.
|
|
47
|
+
|
|
48
|
+
## Documentation
|
|
49
|
+
|
|
50
|
+
| Guide | What it covers |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| [Getting Started](docs/getting-started.md) | Installation, authentication, profiles (multiple clients), sandbox mode, output formats, pagination, global options |
|
|
53
|
+
| [Accounts](docs/accounts.md) | Monetary accounts: list, get, create savings goals |
|
|
54
|
+
| [Payments](docs/payments.md) | Payments, drafts, scheduled payments, batch payments, international transfers |
|
|
55
|
+
| [Payment Requests](docs/payment-requests.md) | Request money from others, manage incoming requests |
|
|
56
|
+
| [Cards](docs/cards.md) | Card management: list, get, update settings, freeze/unfreeze |
|
|
57
|
+
| [Callbacks](docs/callbacks.md) | Notification filters and webhook management |
|
|
58
|
+
|
|
59
|
+
## Tech stack
|
|
60
|
+
|
|
61
|
+
- [Python](https://www.python.org/) >= 3.11
|
|
62
|
+
- [Click](https://click.palletsprojects.com/) for the CLI framework
|
|
63
|
+
- [bunq SDK](https://github.com/bunq/sdk_python) for the Bunq API
|
|
64
|
+
- [Tabulate](https://pypi.org/project/tabulate/) for table formatting
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
MIT -- see [LICENSE](LICENSE).
|