pyflexweb 0.1.2__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.
pyflexweb/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """
2
+ PyFlexWeb - Interactive Brokers Flex Web Service Client
3
+
4
+ A command-line tool to easily download IBKR Flex Activity and Trade Confirmation reports.
5
+ """
6
+
7
+ try:
8
+ from importlib.metadata import version
9
+ except ImportError:
10
+ # Python < 3.8
11
+ from importlib_metadata import version # type: ignore
12
+
13
+ __version__ = version("pyflexweb")
pyflexweb/cli.py ADDED
@@ -0,0 +1,213 @@
1
+ """
2
+ Command-line interface for PyFlexWeb.
3
+
4
+ This module provides the main entry point and argument parsing for the PyFlexWeb CLI.
5
+ """
6
+
7
+ import sys
8
+
9
+ import click
10
+
11
+ from .database import FlexDatabase
12
+ from .handlers import (
13
+ handle_download_command,
14
+ handle_fetch_command,
15
+ handle_query_command,
16
+ handle_request_command,
17
+ handle_token_command,
18
+ )
19
+
20
+
21
+ # Common options
22
+ def common_options(func):
23
+ """Common options for commands that fetch reports."""
24
+ func = click.option("--output", help="Output filename (for single report downloads only)")(func)
25
+ func = click.option(
26
+ "--output-dir",
27
+ help="Directory to save reports (default: current directory)",
28
+ )(func)
29
+ func = click.option(
30
+ "--poll-interval",
31
+ type=int,
32
+ default=30,
33
+ help="Seconds to wait between polling attempts (default: 30)",
34
+ )(func)
35
+ func = click.option(
36
+ "--max-attempts",
37
+ type=int,
38
+ default=20,
39
+ help="Maximum number of polling attempts (default: 20)",
40
+ )(func)
41
+ return func
42
+
43
+
44
+ @click.group(invoke_without_command=True)
45
+ @click.version_option(package_name="pyflexweb")
46
+ @click.pass_context
47
+ def cli(ctx):
48
+ """Download IBKR Flex reports using the Interactive Brokers flex web service."""
49
+ db = FlexDatabase()
50
+ ctx.ensure_object(dict)
51
+ ctx.obj["db"] = db
52
+
53
+ # If no command is provided, show help text
54
+ if ctx.invoked_subcommand is None:
55
+ click.echo(ctx.get_help())
56
+ exit(1)
57
+
58
+ return 0
59
+
60
+
61
+ # Token commands
62
+ @cli.group()
63
+ @click.pass_context
64
+ def token(ctx):
65
+ """Manage IBKR Flex token."""
66
+ pass
67
+
68
+
69
+ @token.command("set")
70
+ @click.argument("token_value")
71
+ @click.pass_context
72
+ def token_set(ctx, token_value):
73
+ """Set your IBKR token."""
74
+ args = type("Args", (), {"subcommand": "set", "token": token_value})
75
+ return handle_token_command(args, ctx.obj["db"])
76
+
77
+
78
+ @token.command("get")
79
+ @click.pass_context
80
+ def token_get(ctx):
81
+ """Display your stored token."""
82
+ args = type("Args", (), {"subcommand": "get"})
83
+ return handle_token_command(args, ctx.obj["db"])
84
+
85
+
86
+ @token.command("unset")
87
+ @click.pass_context
88
+ def token_unset(ctx):
89
+ """Remove your stored token."""
90
+ args = type("Args", (), {"subcommand": "unset"})
91
+ return handle_token_command(args, ctx.obj["db"])
92
+
93
+
94
+ # Query commands
95
+ @cli.group(invoke_without_command=True)
96
+ @click.pass_context
97
+ def query(ctx):
98
+ """Manage Flex query IDs."""
99
+ if ctx.invoked_subcommand is None:
100
+ # Default to 'list' if no subcommand is provided
101
+ args = type("Args", (), {"subcommand": "list"})
102
+ return handle_query_command(args, ctx.obj["db"])
103
+ return 0
104
+
105
+
106
+ @query.command("add")
107
+ @click.argument("query_id")
108
+ @click.option("--name", required=True, help="A descriptive name for the query")
109
+ @click.pass_context
110
+ def query_add(ctx, query_id, name):
111
+ """Add a new query ID."""
112
+ args = type("Args", (), {"subcommand": "add", "query_id": query_id, "name": name})
113
+ return handle_query_command(args, ctx.obj["db"])
114
+
115
+
116
+ @query.command("remove")
117
+ @click.argument("query_id")
118
+ @click.pass_context
119
+ def query_remove(ctx, query_id):
120
+ """Remove a query ID."""
121
+ args = type("Args", (), {"subcommand": "remove", "query_id": query_id})
122
+ return handle_query_command(args, ctx.obj["db"])
123
+
124
+
125
+ @query.command("rename")
126
+ @click.argument("query_id")
127
+ @click.option("--name", required=True, help="The new name for the query")
128
+ @click.pass_context
129
+ def query_rename(ctx, query_id, name):
130
+ """Rename a query."""
131
+ args = type("Args", (), {"subcommand": "rename", "query_id": query_id, "name": name})
132
+ return handle_query_command(args, ctx.obj["db"])
133
+
134
+
135
+ @query.command("list")
136
+ @click.pass_context
137
+ def query_list(ctx):
138
+ """List all stored query IDs."""
139
+ args = type("Args", (), {"subcommand": "list"})
140
+ return handle_query_command(args, ctx.obj["db"])
141
+
142
+
143
+ # Report request command
144
+ @cli.command("request")
145
+ @click.argument("query_id")
146
+ @click.pass_context
147
+ def request(ctx, query_id):
148
+ """Request a Flex report."""
149
+ args = type("Args", (), {"query_id": query_id})
150
+ return handle_request_command(args, ctx.obj["db"])
151
+
152
+
153
+ # Report fetch command
154
+ @cli.command("fetch")
155
+ @click.argument("request_id")
156
+ @common_options
157
+ @click.pass_context
158
+ def fetch(ctx, request_id, output, output_dir, poll_interval, max_attempts):
159
+ """Fetch a requested report."""
160
+ args = type(
161
+ "Args",
162
+ (),
163
+ {
164
+ "request_id": request_id,
165
+ "output": output,
166
+ "output_dir": output_dir,
167
+ "poll_interval": poll_interval,
168
+ "max_attempts": max_attempts,
169
+ },
170
+ )
171
+ return handle_fetch_command(args, ctx.obj["db"])
172
+
173
+
174
+ # All-in-one download command
175
+ @cli.command("download")
176
+ @click.option("--query", default="all", help="The query ID to download a report for (default: all)")
177
+ @click.option("--force", is_flag=True, help="Force download even if report was already downloaded today")
178
+ @common_options
179
+ @click.pass_context
180
+ def download(ctx, query, force, output, output_dir, poll_interval, max_attempts):
181
+ """Request and download a report in one step.
182
+
183
+ If --query is not specified, downloads all queries not updated in 24 hours.
184
+ """
185
+ args = type(
186
+ "Args",
187
+ (),
188
+ {
189
+ "query": query,
190
+ "force": force,
191
+ "output": output,
192
+ "output_dir": output_dir,
193
+ "poll_interval": poll_interval,
194
+ "max_attempts": max_attempts,
195
+ },
196
+ )
197
+ return handle_download_command(args, ctx.obj["db"])
198
+
199
+
200
+ def main():
201
+ """Main entry point for the CLI."""
202
+ try:
203
+ sys.exit(cli()) # pylint: disable=no-value-for-parameter
204
+ except Exception as e: # pylint: disable=broad-except
205
+ click.echo(f"Error: {e}", err=True)
206
+ return 1
207
+ finally:
208
+ # No need to close db here as it's managed within the cli context
209
+ pass
210
+
211
+
212
+ if __name__ == "__main__":
213
+ sys.exit(main())
pyflexweb/client.py ADDED
@@ -0,0 +1,75 @@
1
+ """Client module for communicating with IBKR Flex Web Service."""
2
+
3
+ import sys
4
+ import xml.etree.ElementTree as ET
5
+
6
+ import requests
7
+
8
+
9
+ class IBKRFlexClient:
10
+ """Handles communication with the IBKR Flex Web Service."""
11
+
12
+ BASE_URL = "https://gdcdyn.interactivebrokers.com/Universal/servlet"
13
+ REQUEST_URL = f"{BASE_URL}/FlexStatementService.SendRequest"
14
+ STATEMENT_URL = f"{BASE_URL}/FlexStatementService.GetStatement"
15
+
16
+ def __init__(self, token: str):
17
+ self.token = token
18
+
19
+ def request_report(self, query_id: str) -> str | None:
20
+ """Request a report from IBKR and return the request ID if successful."""
21
+ url = f"{self.REQUEST_URL}?t={self.token}&q={query_id}&v=3"
22
+
23
+ try:
24
+ response = requests.get(url)
25
+ response.raise_for_status()
26
+
27
+ # Parse the XML response
28
+ root = ET.fromstring(response.text)
29
+ status = root.find(".//Status").text
30
+
31
+ if status == "Success":
32
+ request_id = root.find(".//ReferenceCode").text
33
+ return request_id
34
+ else:
35
+ error = root.find(".//ErrorMessage").text
36
+ print(f"Error requesting report: {error}", file=sys.stderr)
37
+ return None
38
+
39
+ except requests.exceptions.RequestException as e:
40
+ print(f"Network error: {e}", file=sys.stderr)
41
+ return None
42
+ except ET.ParseError as e:
43
+ print(f"Error parsing response: {e}", file=sys.stderr)
44
+ return None
45
+
46
+ def get_report(self, request_id: str) -> str | None:
47
+ """Get a report using the request ID. Returns the XML content if successful."""
48
+ url = f"{self.STATEMENT_URL}?t={self.token}&q={request_id}&v=3"
49
+
50
+ try:
51
+ response = requests.get(url)
52
+ response.raise_for_status()
53
+
54
+ # Check if this is an error response
55
+ if "<ErrorCode>" in response.text:
56
+ root = ET.fromstring(response.text)
57
+ status = root.find(".//Status").text
58
+
59
+ if status == "Pending":
60
+ return None # Report not ready yet
61
+
62
+ error = root.find(".//ErrorMessage")
63
+ if error is not None:
64
+ print(f"Error retrieving report: {error.text}", file=sys.stderr)
65
+ return None
66
+
67
+ # If we got here, we have the actual report
68
+ return response.text
69
+
70
+ except requests.exceptions.RequestException as e:
71
+ print(f"Network error: {e}", file=sys.stderr)
72
+ return None
73
+ except ET.ParseError as e:
74
+ print(f"Error parsing response: {e}", file=sys.stderr)
75
+ return None
pyflexweb/database.py ADDED
@@ -0,0 +1,293 @@
1
+ """Database module for storing tokens, queries, and request history."""
2
+
3
+ import os
4
+ import sqlite3
5
+ from datetime import datetime, timedelta
6
+
7
+ import platformdirs
8
+
9
+
10
+ class FlexDatabase:
11
+ """Manages the local database for tokens, queries, and request history."""
12
+
13
+ DB_VERSION = 2 # Increment when schema changes
14
+
15
+ def __init__(self):
16
+ self.db_dir = platformdirs.user_data_dir("pyflexweb")
17
+ os.makedirs(self.db_dir, exist_ok=True)
18
+ self.db_path = os.path.join(self.db_dir, "status.db")
19
+ self.conn = self._init_db()
20
+
21
+ def get_db_path(self) -> str:
22
+ """Return the path to the database file."""
23
+ return self.db_path
24
+
25
+ def _init_db(self) -> sqlite3.Connection:
26
+ conn = sqlite3.connect(self.db_path)
27
+ cursor = conn.cursor()
28
+
29
+ # Create tables if they don't exist
30
+ cursor.execute(
31
+ """
32
+ CREATE TABLE IF NOT EXISTS config (
33
+ key TEXT PRIMARY KEY,
34
+ value TEXT NOT NULL
35
+ )
36
+ """
37
+ )
38
+
39
+ cursor.execute(
40
+ """
41
+ CREATE TABLE IF NOT EXISTS queries (
42
+ id TEXT PRIMARY KEY,
43
+ name TEXT,
44
+ added_on DATETIME DEFAULT CURRENT_TIMESTAMP
45
+ )
46
+ """
47
+ )
48
+
49
+ cursor.execute(
50
+ """
51
+ CREATE TABLE IF NOT EXISTS requests (
52
+ request_id TEXT PRIMARY KEY,
53
+ query_id TEXT,
54
+ status TEXT,
55
+ requested_at DATETIME,
56
+ completed_at DATETIME,
57
+ last_updated DATETIME,
58
+ output_path TEXT,
59
+ FOREIGN KEY (query_id) REFERENCES queries(id)
60
+ )
61
+ """
62
+ )
63
+
64
+ # Check if migration needed
65
+ self._check_migration(conn)
66
+
67
+ return conn
68
+
69
+ def _check_migration(self, conn: sqlite3.Connection) -> None:
70
+ """Check if database needs migration and perform if needed."""
71
+ cursor = conn.cursor()
72
+
73
+ # Get current database version
74
+ cursor.execute("SELECT value FROM config WHERE key = 'db_version' LIMIT 1")
75
+ result = cursor.fetchone()
76
+ current_version = int(result[0]) if result else 0
77
+
78
+ # If already at current version, no migration needed
79
+ if current_version >= self.DB_VERSION:
80
+ return
81
+
82
+ # Perform migrations
83
+ if current_version < 1:
84
+ # Add last_updated column to requests table
85
+ try:
86
+ cursor.execute("ALTER TABLE requests ADD COLUMN last_updated DATETIME")
87
+ conn.commit()
88
+ except sqlite3.OperationalError:
89
+ # Column might already exist
90
+ pass
91
+
92
+ if current_version < 2:
93
+ # Remove report_type column from queries table
94
+ # First get all existing queries
95
+ cursor.execute("PRAGMA table_info(queries)")
96
+ columns = cursor.fetchall()
97
+ has_report_type = any(col[1] == "report_type" for col in columns)
98
+
99
+ if has_report_type:
100
+ # Get all existing data
101
+ cursor.execute("SELECT id, name FROM queries")
102
+ queries = cursor.fetchall()
103
+
104
+ # Drop and recreate the table
105
+ cursor.execute("DROP TABLE queries")
106
+ cursor.execute(
107
+ """
108
+ CREATE TABLE queries (
109
+ id TEXT PRIMARY KEY,
110
+ name TEXT,
111
+ added_on DATETIME DEFAULT CURRENT_TIMESTAMP
112
+ )
113
+ """
114
+ )
115
+
116
+ # Reinsert the data
117
+ for query_id, name in queries:
118
+ cursor.execute("INSERT INTO queries (id, name) VALUES (?, ?)", (query_id, name))
119
+
120
+ # Update the version
121
+ cursor.execute(
122
+ "INSERT OR REPLACE INTO config VALUES (?, ?)",
123
+ ("db_version", str(self.DB_VERSION)),
124
+ )
125
+ conn.commit()
126
+
127
+ def set_token(self, token: str) -> None:
128
+ cursor = self.conn.cursor()
129
+ cursor.execute("INSERT OR REPLACE INTO config VALUES (?, ?)", ("token", token))
130
+ self.conn.commit()
131
+
132
+ def get_token(self) -> str | None:
133
+ cursor = self.conn.cursor()
134
+ cursor.execute("SELECT value FROM config WHERE key = ?", ("token",))
135
+ result = cursor.fetchone()
136
+ return result[0] if result else None
137
+
138
+ def unset_token(self) -> None:
139
+ cursor = self.conn.cursor()
140
+ cursor.execute("DELETE FROM config WHERE key = ?", ("token",))
141
+ self.conn.commit()
142
+
143
+ def add_query(self, query_id: str, name: str) -> None:
144
+ cursor = self.conn.cursor()
145
+ cursor.execute("INSERT OR REPLACE INTO queries (id, name) VALUES (?, ?)", (query_id, name))
146
+ self.conn.commit()
147
+
148
+ def remove_query(self, query_id: str) -> bool:
149
+ cursor = self.conn.cursor()
150
+ cursor.execute("DELETE FROM queries WHERE id = ?", (query_id,))
151
+ self.conn.commit()
152
+ return cursor.rowcount > 0
153
+
154
+ def rename_query(self, query_id: str, new_name: str) -> bool:
155
+ cursor = self.conn.cursor()
156
+ cursor.execute("UPDATE queries SET name = ? WHERE id = ?", (new_name, query_id))
157
+ self.conn.commit()
158
+ return cursor.rowcount > 0
159
+
160
+ def list_queries(self) -> list[tuple[str, str]]:
161
+ cursor = self.conn.cursor()
162
+ cursor.execute("SELECT id, name FROM queries ORDER BY added_on")
163
+ return cursor.fetchall()
164
+
165
+ def add_request(self, request_id: str, query_id: str) -> None:
166
+ cursor = self.conn.cursor()
167
+ cursor.execute(
168
+ "INSERT INTO requests (request_id, query_id, status, requested_at) VALUES (?, ?, ?, ?)",
169
+ (request_id, query_id, "pending", datetime.now().isoformat()),
170
+ )
171
+ self.conn.commit()
172
+
173
+ def update_request_status(self, request_id: str, status: str, output_path: str | None = None) -> None:
174
+ cursor = self.conn.cursor()
175
+ now = datetime.now().isoformat()
176
+
177
+ if status == "completed":
178
+ cursor.execute(
179
+ "UPDATE requests SET status = ?, completed_at = ?, output_path = ?, last_updated = ? WHERE request_id = ?",
180
+ (status, now, output_path, now, request_id),
181
+ )
182
+ else:
183
+ cursor.execute(
184
+ "UPDATE requests SET status = ?, last_updated = ? WHERE request_id = ?",
185
+ (status, now, request_id),
186
+ )
187
+ self.conn.commit()
188
+
189
+ def get_request_info(self, request_id: str) -> dict | None:
190
+ cursor = self.conn.cursor()
191
+ cursor.execute(
192
+ "SELECT request_id, query_id, status, requested_at, completed_at, output_path FROM requests WHERE request_id = ?",
193
+ (request_id,),
194
+ )
195
+ result = cursor.fetchone()
196
+ if not result:
197
+ return None
198
+
199
+ return {
200
+ "request_id": result[0],
201
+ "query_id": result[1],
202
+ "status": result[2],
203
+ "requested_at": result[3],
204
+ "completed_at": result[4],
205
+ "output_path": result[5],
206
+ }
207
+
208
+ def get_query_info(self, query_id: str) -> dict | None:
209
+ cursor = self.conn.cursor()
210
+ cursor.execute("SELECT id, name FROM queries WHERE id = ?", (query_id,))
211
+ result = cursor.fetchone()
212
+ if not result:
213
+ return None
214
+
215
+ return {"id": result[0], "name": result[1]}
216
+
217
+ def get_latest_request(self, query_id: str) -> dict | None:
218
+ cursor = self.conn.cursor()
219
+ cursor.execute(
220
+ "SELECT request_id FROM requests WHERE query_id = ? ORDER BY requested_at DESC LIMIT 1",
221
+ (query_id,),
222
+ )
223
+ result = cursor.fetchone()
224
+ if not result:
225
+ return None
226
+
227
+ return self.get_request_info(result[0])
228
+
229
+ def get_queries_not_updated(self, hours: int = 24) -> list[dict]:
230
+ """Get queries that haven't been updated in the specified hours."""
231
+ cursor = self.conn.cursor()
232
+ cutoff_time = (datetime.now() - timedelta(hours=hours)).isoformat()
233
+
234
+ # Get all queries
235
+ cursor.execute("SELECT id, name FROM queries")
236
+ all_queries = cursor.fetchall()
237
+
238
+ result = []
239
+ for query_id, name in all_queries:
240
+ # Check if this query has a successful request within the time period
241
+ cursor.execute(
242
+ """
243
+ SELECT r.request_id FROM requests r
244
+ WHERE r.query_id = ?
245
+ AND r.status = 'completed'
246
+ AND (r.last_updated > ? OR r.completed_at > ?)
247
+ ORDER BY r.last_updated DESC
248
+ LIMIT 1
249
+ """,
250
+ (query_id, cutoff_time, cutoff_time),
251
+ )
252
+
253
+ recent_request = cursor.fetchone()
254
+
255
+ if not recent_request:
256
+ # No recent successful request, include this query
257
+ result.append({"id": query_id, "name": name})
258
+
259
+ return result
260
+
261
+ def get_query_with_last_request(self, query_id: str) -> dict | None:
262
+ """Get query info along with its latest request."""
263
+ query_info = self.get_query_info(query_id)
264
+ if not query_info:
265
+ return None
266
+
267
+ latest_request = self.get_latest_request(query_id)
268
+ if latest_request:
269
+ query_info["latest_request"] = latest_request
270
+
271
+ return query_info
272
+
273
+ def get_all_queries_with_status(self) -> list[dict]:
274
+ """Get all queries with their latest request status."""
275
+ cursor = self.conn.cursor()
276
+ cursor.execute("SELECT id, name FROM queries ORDER BY added_on")
277
+ queries = cursor.fetchall()
278
+
279
+ result = []
280
+ for query_id, name in queries:
281
+ query_info = {"id": query_id, "name": name, "latest_request": None}
282
+
283
+ # Get latest request for this query
284
+ latest_request = self.get_latest_request(query_id)
285
+ if latest_request:
286
+ query_info["latest_request"] = latest_request
287
+
288
+ result.append(query_info)
289
+
290
+ return result
291
+
292
+ def close(self) -> None:
293
+ self.conn.close()