beancount-gocardless 0.1.13__py3-none-any.whl → 0.1.14__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.
@@ -1,669 +0,0 @@
1
- import os
2
- import sys
3
- from textual.app import App, ComposeResult
4
- from textual.containers import Container, Vertical, ScrollableContainer
5
- from textual.widgets import Header, Footer, Button, Static, Input, Label
6
- from textual.message import Message
7
- from textual.binding import Binding
8
- from rich.table import Table
9
- from rich.text import Text
10
- from typing import Optional
11
- from beancount_gocardless.client import GoCardlessClient
12
- from requests.exceptions import HTTPError as HttpServiceException
13
- import logging
14
-
15
- logger = logging.getLogger(__name__)
16
-
17
-
18
- class ActionMessage(Message):
19
- """Custom message to signal actions within the TUI."""
20
-
21
- def __init__(self, action: str, payload: Optional[dict] = None) -> None:
22
- super().__init__()
23
- self.action = action
24
- self.payload = payload or {}
25
-
26
-
27
- class MenuView(Static):
28
- """Main menu view."""
29
-
30
- def compose(self) -> ComposeResult:
31
- yield Vertical(
32
- Label("GoCardLess TUI - Main Menu", id="title"),
33
- Button("List Banks", id="list_banks", variant="primary"),
34
- Button("List Linked Accounts", id="list_accounts", variant="primary"),
35
- Button("Get Account Balance", id="get_balance", variant="primary"),
36
- Button("Create New Link", id="create_link", variant="success"),
37
- Button("Delete Existing Link", id="delete_link", variant="error"),
38
- Button("Quit", id="quit", variant="default"),
39
- id="menu_view_vertical",
40
- )
41
-
42
- def on_button_pressed(self, event: Button.Pressed) -> None:
43
- self.post_message(ActionMessage(event.button.id))
44
-
45
-
46
- class BaseSubView(Static):
47
- """Base class for sub-views with a back button."""
48
-
49
- def compose(self) -> ComposeResult:
50
- yield from self.compose_content()
51
-
52
- def compose_content(self) -> ComposeResult:
53
- # To be overridden by subclasses
54
- yield Static("Content goes here")
55
-
56
- async def on_button_pressed(self, event: Button.Pressed) -> None:
57
- if event.button.id == "back_to_menu":
58
- self.post_message(ActionMessage("show_menu"))
59
- else:
60
- # Allow subclasses to handle other buttons
61
- pass
62
-
63
-
64
- class BanksView(BaseSubView):
65
- """View to display list of banks with country and name filtering."""
66
-
67
- def __init__(self, client: GoCardlessClient, country: Optional[str] = None):
68
- super().__init__()
69
- self.client = client
70
- self.country = country
71
- self.all_banks = None # Store all banks
72
- self.status_message = Static("Loading banks...", id="banks_status_message")
73
- self.banks_table = Static("", id="banks_table")
74
-
75
- def compose_content(self) -> ComposeResult:
76
- yield Label("Banks", classes="view_title")
77
- yield Input(placeholder="Filter banks by name...", id="bank_filter_input")
78
- yield Input(
79
- value=self.country,
80
- placeholder="Country (e.g. FR)",
81
- id="country_filter_input",
82
- max_length=2,
83
- )
84
- yield self.status_message
85
- yield ScrollableContainer(
86
- self.banks_table,
87
- id="banks_scrollable_area",
88
- classes="horizontal-scroll",
89
- )
90
- yield Button("Back to Menu", id="back_to_menu", classes="back_button")
91
-
92
- # Override the base class compose to avoid duplicate back button
93
- def compose(self) -> ComposeResult:
94
- yield Vertical(
95
- *self.compose_content(),
96
- id="banks_view_vertical",
97
- )
98
-
99
- async def on_mount(self) -> None:
100
- """Load all banks on mount."""
101
- await self.filter_banks("", "")
102
-
103
- async def filter_banks(
104
- self, name_filter: Optional[str] = "", country_code: Optional[str] = None
105
- ) -> None:
106
- """Filters the banks based on current country and name filter."""
107
- try:
108
- if not self.all_banks:
109
- self.status_message.update("Loading all banks...")
110
- self.all_banks = [
111
- inst.model_dump()
112
- for inst in self.client.get_institutions(country=None)
113
- ]
114
-
115
- self.banks_table.update(
116
- f"Loading banks... {name_filter} {country_code} {len(self.all_banks)}"
117
- )
118
-
119
- name_filtered = [
120
- b
121
- for b in self.all_banks
122
- if (name_filter.upper() in b.get("name", "").upper() or not name_filter)
123
- and (country_code in b.get("countries", []) or not country_code)
124
- ]
125
-
126
- if not name_filtered:
127
- filter_msg = f" matching '{name_filter}'" if name_filter else ""
128
- self.banks_table.update(
129
- f"No banks found in {country_code}{filter_msg}. Total: {len(self.all_banks)} available."
130
- )
131
- self.status_message.update("")
132
- return
133
-
134
- # Create a formatted table for display
135
- table = Table(title=None, expand=True, min_width=80)
136
- table.add_column("Name", overflow="ellipsis", min_width=30)
137
- table.add_column("ID", overflow="ellipsis", min_width=20)
138
- table.add_column("Countries", overflow="ellipsis", min_width=15)
139
-
140
- for bank in name_filtered:
141
- countries = ", ".join(bank.get("countries", []))
142
- table.add_row(bank.get("name", "N/A"), bank.get("id", "N/A"), countries)
143
-
144
- # Update the table content
145
- self.banks_table.update(table)
146
- self.status_message.update(f"Showing {len(name_filtered)} banks")
147
-
148
- except Exception as e:
149
- self.status_message.update(
150
- Text(f"Error filtering banks: {e}", style="bold red")
151
- )
152
-
153
- async def on_input_changed(self, event: Input.Changed) -> None:
154
- """Event handler for input changes in either filter input."""
155
- input_id = event.input.id
156
- if input_id in ["country_filter_input", "bank_filter_input"]:
157
- name_input = self.query_one("#bank_filter_input", Input)
158
- country_input = self.query_one("#country_filter_input", Input)
159
- country_code = country_input.value.upper()
160
- omit_country = len(country_code) != 2
161
-
162
- await self.filter_banks(
163
- name_filter=name_input.value,
164
- country_code=country_code if not omit_country else None,
165
- )
166
-
167
- async def on_button_pressed(self, event: Button.Pressed) -> None:
168
- """Handle button presses within this view."""
169
- if event.button.id == "back_to_menu":
170
- self.post_message(ActionMessage("show_menu"))
171
-
172
-
173
- class AccountsView(BaseSubView):
174
- """View to display linked accounts with complete information."""
175
-
176
- def __init__(self, client: GoCardlessClient):
177
- super().__init__()
178
- self.client = client
179
- self.status_message = Static(
180
- "Loading accounts...", id="accounts_status_message"
181
- )
182
- self.accounts_table = Static("", id="accounts_table")
183
- self.pending_delete_ref = None
184
- self.accounts_list = []
185
-
186
- def compose_content(self) -> ComposeResult:
187
- yield Label("Linked Accounts", classes="view_title")
188
- yield self.status_message
189
- yield ScrollableContainer(
190
- self.accounts_table,
191
- id="accounts_scrollable_area",
192
- classes="horizontal-scroll",
193
- )
194
- yield Vertical(
195
- Label("Select Account by # and Delete Link"),
196
- Input(placeholder="Enter account #", id="select_account_input"),
197
- Button("Select", id="select_account_button"),
198
- Input(placeholder="Reference", id="delete_ref_input", disabled=True),
199
- Button(
200
- "Delete Link", id="delete_link_button", variant="error", disabled=True
201
- ),
202
- Button(
203
- "Confirm Delete",
204
- id="confirm_delete_button",
205
- variant="error",
206
- disabled=True,
207
- ),
208
- Button("Cancel", id="cancel_delete_button", disabled=True),
209
- id="delete_section",
210
- )
211
- yield Button("Back to Menu", id="back_to_menu", classes="back_button")
212
-
213
- # Override the base class compose to avoid duplicate back button
214
- def compose(self) -> ComposeResult:
215
- yield Vertical(
216
- *self.compose_content(),
217
- id="accounts_view_vertical",
218
- )
219
-
220
- async def on_mount(self) -> None:
221
- await self.load_accounts()
222
-
223
- async def load_accounts(self) -> None:
224
- """Loads and displays the linked accounts with detailed information."""
225
- try:
226
- self.status_message.update("Loading accounts...")
227
- accounts = self.client.get_all_accounts()
228
- self.accounts_list = accounts
229
- if not accounts:
230
- self.accounts_table.update("No accounts found.")
231
- self.status_message.update("")
232
- return
233
-
234
- # Create a formatted table for display
235
- table = Table(title=None, expand=True, min_width=100)
236
- table.add_column("#", style="dim", width=3)
237
- table.add_column("Bank", overflow="ellipsis", min_width=20)
238
- table.add_column("Name", overflow="ellipsis", min_width=20)
239
- table.add_column("ID", overflow="ellipsis", min_width=20)
240
- table.add_column("IBAN", overflow="ellipsis", min_width=20)
241
- table.add_column("Reference", overflow="ellipsis", min_width=15)
242
- table.add_column("Status", width=6)
243
- table.add_column("Last Accessed", min_width=12)
244
-
245
- # Format and add accounts to the table
246
- for idx, account in enumerate(accounts, start=1):
247
- # Get values with fallbacks for any missing keys
248
- institution_id = account.get("institution_id", "N/A")
249
- name = account.get("name", account.get("owner_name", "N/A"))
250
- iban = account.get("iban", "N/A")
251
- account_id = account.get("id", "N/A")
252
- reference = account.get("requisition_reference", "N/A")
253
- status = account.get("status", "N/A")
254
- last_accessed = account.get("last_accessed", "")
255
- if last_accessed:
256
- try:
257
- last_accessed = last_accessed.split("T")[0]
258
- except Exception:
259
- pass
260
-
261
- table.add_row(
262
- str(idx),
263
- institution_id,
264
- name,
265
- account_id,
266
- iban,
267
- reference,
268
- status,
269
- last_accessed,
270
- )
271
-
272
- # Update the table content
273
- self.accounts_table.update(table)
274
- if not accounts:
275
- self.accounts_table.add_row(
276
- "No accounts found.", "", "", "", "", "", "", ""
277
- )
278
- self.status_message.update("")
279
- return
280
-
281
- # Format and add accounts to the table
282
- for idx, account in enumerate(accounts, start=1):
283
- # Get values with fallbacks for any missing keys
284
- institution_id = account.get("institution_id", "N/A")
285
- name = account.get("name", account.get("owner_name", "N/A"))
286
- iban = account.get("iban", "N/A")
287
- account_id = account.get("id", "N/A")
288
- reference = account.get("requisition_reference", "N/A")
289
- status = account.get("status", "N/A")
290
- last_accessed = account.get("last_accessed", "")
291
- if last_accessed:
292
- try:
293
- last_accessed = last_accessed.split("T")[0]
294
- except Exception:
295
- pass
296
-
297
- self.accounts_table.add_row(
298
- str(idx),
299
- institution_id,
300
- name,
301
- account_id,
302
- iban,
303
- reference,
304
- status,
305
- last_accessed,
306
- )
307
- self.status_message.update("")
308
-
309
- except HttpServiceException as e:
310
- self.status_message.update(Text(f"API Error: {e}", style="bold red"))
311
- except Exception as e:
312
- self.status_message.update(Text(f"Unexpected error: {e}", style="bold red"))
313
-
314
- async def on_button_pressed(self, event: Button.Pressed) -> None:
315
- """Handle button presses within this view."""
316
- if event.button.id == "back_to_menu":
317
- self.post_message(ActionMessage("show_menu"))
318
-
319
- async def on_button_pressed(self, event: Button.Pressed) -> None:
320
- """Handle button presses within this view."""
321
- if event.button.id == "back_to_menu":
322
- self.post_message(ActionMessage("show_menu"))
323
- elif event.button.id == "select_account_button":
324
- select_input = self.query_one("#select_account_input", Input)
325
- try:
326
- idx = int(select_input.value.strip()) - 1 # 1-based to 0-based
327
- if 0 <= idx < len(self.accounts_list):
328
- reference = self.accounts_list[idx].get("requisition_reference", "")
329
- ref_input = self.query_one("#delete_ref_input", Input)
330
- ref_input.value = reference
331
- delete_btn = self.query_one("#delete_link_button", Button)
332
- delete_btn.disabled = False
333
- self.status_message.update(
334
- f"Selected account {idx + 1}, reference: {reference}"
335
- )
336
- else:
337
- self.status_message.update("Invalid account number.")
338
- except ValueError:
339
- self.status_message.update("Please enter a valid number.")
340
- elif event.button.id == "delete_link_button":
341
- ref_input = self.query_one("#delete_ref_input", Input)
342
- reference = ref_input.value.strip()
343
- if reference:
344
- self.pending_delete_ref = reference
345
- self.status_message.update(
346
- Text(
347
- f"Are you sure to delete link with reference '{reference}'?",
348
- style="bold yellow",
349
- )
350
- )
351
- confirm_btn = self.query_one("#confirm_delete_button", Button)
352
- cancel_btn = self.query_one("#cancel_delete_button", Button)
353
- confirm_btn.disabled = False
354
- cancel_btn.disabled = False
355
- else:
356
- self.status_message.update(
357
- Text("No reference selected.", style="bold yellow")
358
- )
359
- elif event.button.id == "confirm_delete_button":
360
- if self.pending_delete_ref:
361
- await self.process_delete_link(self.pending_delete_ref)
362
- select_input = self.query_one("#select_account_input", Input)
363
- ref_input = self.query_one("#delete_ref_input", Input)
364
- select_input.value = ""
365
- ref_input.value = ""
366
- delete_btn = self.query_one("#delete_link_button", Button)
367
- delete_btn.disabled = True
368
- self.pending_delete_ref = None
369
- confirm_btn = self.query_one("#confirm_delete_button", Button)
370
- cancel_btn = self.query_one("#cancel_delete_button", Button)
371
- confirm_btn.disabled = True
372
- cancel_btn.disabled = True
373
- elif event.button.id == "cancel_delete_button":
374
- self.pending_delete_ref = None
375
- self.status_message.update("Delete cancelled.")
376
- confirm_btn = self.query_one("#confirm_delete_button", Button)
377
- cancel_btn = self.query_one("#cancel_delete_button", Button)
378
- confirm_btn.disabled = True
379
- cancel_btn.disabled = True
380
- elif event.button.id == "cancel_delete_button":
381
- self.pending_delete_ref = None
382
- self.status_message.update("Delete cancelled.")
383
- confirm_btn = self.query_one("#confirm_delete_button", Button)
384
- cancel_btn = self.query_one("#cancel_delete_button", Button)
385
- confirm_btn.disabled = True
386
- cancel_btn.disabled = True
387
- elif event.button.id == "do_create_link_button":
388
- await self.process_create_link()
389
-
390
- async def process_create_link(self) -> None:
391
- bank_input = self.query_one("#bank_id_input", Input)
392
- ref_input = self.query_one("#ref_input", Input)
393
- result_message_widget = self.query_one("#link_result_message", Static)
394
- create_button = self.query_one("#do_create_link_button", Button)
395
-
396
- bank_id = bank_input.value.strip()
397
- reference = ref_input.value.strip()
398
-
399
- if not bank_id or not reference:
400
- result_message_widget.update(
401
- Text("Bank ID and Reference are required.", style="bold red")
402
- )
403
- return
404
-
405
- try:
406
- bank_input.disabled = True
407
- ref_input.disabled = True
408
- create_button.disabled = True
409
- result_message_widget.update("Creating link...")
410
-
411
- link_url = self.client.create_bank_link(reference, bank_id) # API call
412
-
413
- if link_url:
414
- link_info = {
415
- "status": "created",
416
- "message": "Link created successfully",
417
- "link": link_url,
418
- }
419
- else:
420
- link_info = {
421
- "status": "exists",
422
- "message": "Link already exists for this reference",
423
- "link": None,
424
- }
425
-
426
- msg_parts = [
427
- Text(f"Status: {link_info.get('status', 'N/A')}\n", style="bold")
428
- ]
429
- if link_info.get("message"):
430
- msg_parts.append(Text(f"Message: {link_info['message']}\n"))
431
- if link_info.get("link"):
432
- msg_parts.append(
433
- Text(
434
- "Link URL (copy and open in browser to authorize):\n",
435
- style="bold yellow",
436
- )
437
- )
438
- msg_parts.append(Text(f"{link_info['link']}", style="underline blue"))
439
-
440
- result_message_widget.update(Text.assemble(*msg_parts))
441
-
442
- except HttpServiceException as e:
443
- result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
444
- except Exception as e:
445
- result_message_widget.update(
446
- Text(f"Unexpected Error:\n{e}", style="bold red")
447
- )
448
- finally:
449
- bank_input.disabled = False
450
- ref_input.disabled = False
451
- create_button.disabled = False
452
-
453
-
454
- class DeleteLinkView(BaseSubView):
455
- """View to delete an existing bank link."""
456
-
457
- def __init__(self, client: GoCardlessClient):
458
- super().__init__()
459
- self.client = client
460
-
461
- def compose(self) -> ComposeResult:
462
- """Override compose to yield widgets from compose_content."""
463
- for widget in self.compose_content():
464
- yield widget
465
-
466
- def compose_content(self) -> ComposeResult:
467
- yield Label("Delete Existing Link", classes="view_title")
468
- yield Vertical(
469
- Input(placeholder="Reference of link to delete", id="del_ref_input"),
470
- Button("Delete Link", id="do_delete_link_button", variant="error"),
471
- Static(id="delete_link_result_message", classes="result_message_area"),
472
- id="delete_link_form_area",
473
- )
474
- yield Button("Back to Menu", id="back_to_menu", classes="back_button")
475
-
476
- async def on_mount(self) -> None:
477
- self.query_one("#del_ref_input", Input).focus()
478
-
479
- async def on_button_pressed(self, event: Button.Pressed) -> None:
480
- if event.button.id == "back_to_menu":
481
- self.post_message(ActionMessage("show_menu"))
482
- elif event.button.id == "do_delete_link_button":
483
- await self.process_delete_link()
484
-
485
- async def process_delete_link(self) -> None:
486
- ref_input = self.query_one("#del_ref_input", Input)
487
- result_message_widget = self.query_one("#delete_link_result_message", Static)
488
- delete_button = self.query_one("#do_delete_link_button", Button)
489
-
490
- reference = ref_input.value.strip()
491
-
492
- if not reference:
493
- result_message_widget.update(
494
- Text("Reference is required to delete a link.", style="bold red")
495
- )
496
- return
497
-
498
- try:
499
- ref_input.disabled = True
500
- delete_button.disabled = True
501
- result_message_widget.update(
502
- f"Deleting link with reference '{reference}'..."
503
- )
504
-
505
- req = self.client.find_requisition_by_reference(reference)
506
- if req:
507
- self.client.delete_requisition(req.id) # API call
508
- result = {"status": "deleted", "message": "Link deleted successfully"}
509
- else:
510
- result = {"status": "not_found", "message": "Link not found"}
511
-
512
- style = "bold green" if result["status"] == "deleted" else "bold yellow"
513
- result_message_widget.update(
514
- Text(
515
- f"Status: {result['status']}\nMessage: {result['message']}",
516
- style=style,
517
- )
518
- )
519
-
520
- except HttpServiceException as e:
521
- result_message_widget.update(Text(f"API Error:\n{e}", style="bold red"))
522
- except Exception as e:
523
- result_message_widget.update(
524
- Text(f"Unexpected Error:\n{e}", style="bold red")
525
- )
526
- finally:
527
- ref_input.disabled = False
528
- delete_button.disabled = False
529
-
530
-
531
- class GoCardLessApp(App):
532
- TITLE = "GoCardLess API TUI"
533
- CSS = """
534
- Screen {
535
- align: center middle;
536
- }
537
- #app_container {
538
- width: 100%;
539
- max-width: 300; /* Max width for the main content container */
540
- height: auto;
541
- border: round $primary;
542
- padding: 1 2;
543
- }
544
- #title { /* For MenuView title */
545
- width: 100%;
546
- text-align: center;
547
- padding: 1 0 2 0;
548
- text-style: bold;
549
- }
550
- .view_title { /* For sub-view titles */
551
- width: 100%;
552
- text-align: center;
553
- padding: 0 0 1 0;
554
- text-style: bold underline;
555
- }
556
- #menu_view_vertical > Button {
557
- width: 100%;
558
- margin-bottom: 1;
559
- }
560
- .back_button {
561
- width: 100%;
562
- margin-top: 2;
563
- }
564
- Input, Button { /* General spacing for inputs and buttons in forms */
565
- margin-bottom: 1;
566
- }
567
- .result_message_area {
568
- margin-top: 1;
569
- padding: 1;
570
- border: round $primary-background-darken-2;
571
- min-height: 3;
572
- width: 100%;
573
- }
574
- Vertical { /* Ensure vertical containers take full width by default */
575
- width: 100%;
576
- }
577
- Table {
578
- margin-top: 1;
579
- }
580
- """
581
- BINDINGS = [
582
- Binding("q", "quit", "Quit App", show=True, priority=True),
583
- Binding(
584
- "escape", "show_menu_escape", "Back to Menu", show=True
585
- ), # Fixed: Always show, we'll control visibility elsewhere
586
- ]
587
-
588
- def __init__(self, secret_id=None, secret_key=None):
589
- super().__init__()
590
- sid = secret_id or os.getenv("GOCARDLESS_SECRET_ID")
591
- sk = secret_key or os.getenv("GOCARDLESS_SECRET_KEY")
592
- if not sid or not sk:
593
- logger.error(
594
- "Error: GoCardLess credentials (GOCARDLESS_SECRET_ID, GOCARDLESS_SECRET_KEY) not found.",
595
- )
596
- logger.error(
597
- "Please set them as environment variables or pass via arguments (not implemented in this TUI version).",
598
- )
599
- sys.exit(1) # Exit if no creds
600
- self.client = GoCardlessClient(sid, sk, {})
601
- self._current_view_is_menu = True
602
-
603
- def compose(self) -> ComposeResult:
604
- yield Header()
605
- yield Container(MenuView(), id="app_container")
606
- yield Footer()
607
-
608
- async def on_mount(self) -> None:
609
- # Fixed: removed the call to update_escape_binding since we're showing escape binding always
610
- # and managing its behavior in action_show_menu_escape
611
- pass
612
-
613
- async def switch_view(self, new_view_widget: Static) -> None:
614
- container = self.query_one("#app_container", Container)
615
- await container.remove_children()
616
- await container.mount(new_view_widget)
617
- self._current_view_is_menu = isinstance(new_view_widget, MenuView)
618
-
619
- async def action_show_menu_escape(self) -> None:
620
- """Handle Escape key press to go back to menu."""
621
- if not self._current_view_is_menu:
622
- await self.switch_view(MenuView())
623
-
624
- async def on_action_message(self, msg: ActionMessage) -> None:
625
- action_to_view_map = {
626
- "show_menu": MenuView,
627
- "list_banks": lambda: BanksView(self.client),
628
- "list_accounts": lambda: AccountsView(self.client),
629
- "get_balance": lambda: BalanceView(self.client),
630
- "create_link": lambda: LinkView(self.client),
631
- "delete_link": lambda: DeleteLinkView(self.client),
632
- }
633
-
634
- if msg.action == "quit":
635
- self.exit()
636
- return
637
-
638
- if msg.action in action_to_view_map:
639
- view_constructor = action_to_view_map[msg.action]
640
- new_view = view_constructor()
641
- await self.switch_view(new_view)
642
- else:
643
- # Fallback for unknown action, though should not happen with defined buttons
644
- container = self.query_one("#app_container", Container)
645
- await container.remove_children()
646
- await container.mount(
647
- Static(Text(f"Unknown action: {msg.action}", style="bold red"))
648
- )
649
- self._current_view_is_menu = False # Assuming it's not menu
650
-
651
-
652
- logger.info(os.getenv("GOCARDLESS_SECRET_ID"), os.getenv("GOCARDLESS_SECRET_KEY"))
653
-
654
-
655
- def main():
656
- # For this TUI, credentials must be set as environment variables
657
- # GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY
658
- if not os.getenv("GOCARDLESS_SECRET_ID") or not os.getenv("GOCARDLESS_SECRET_KEY"):
659
- logger.error(
660
- "Error: GOCARDLESS_SECRET_ID and GOCARDLESS_SECRET_KEY environment variables must be set.",
661
- )
662
- sys.exit(1)
663
-
664
- app = GoCardLessApp()
665
- app.run()
666
-
667
-
668
- if __name__ == "__main__":
669
- main()