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/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).