Skip to content

Commit 1aed56a

Browse files
authored
Merge pull request #15 from LaunchPlatform/inbox
Inbox
2 parents 5072f74 + 3ef10ff commit 1aed56a

37 files changed

+3794
-2074
lines changed

.circleci/config.yml

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,26 @@ jobs:
1111
- image: cimg/python:3.10.14
1212
steps:
1313
- checkout
14-
- python/install-packages:
15-
pkg-manager: poetry
16-
args: --all-extras
14+
- run:
15+
name: Install uv
16+
command: pip install uv
1717
- run:
1818
name: Run test
19-
command: poetry run python -m pytest ./tests -svvvv
19+
command: uv run python -m pytest ./tests -svvvv
2020
build-and-publish:
2121
docker:
2222
- image: cimg/python:3.10.14
2323
steps:
2424
- checkout
25-
- python/install-packages:
26-
pkg-manager: poetry
27-
args: --all-extras
2825
- run:
29-
name: config
30-
command: |
31-
poetry config http-basic.pypi "__token__" "${POETRY_PYPI_TOKEN_PYPI}"
26+
name: Install uv
27+
command: pip install uv
3228
- run:
3329
name: Build
34-
command: poetry build
30+
command: uv build
3531
- run:
3632
name: Publish
37-
command: poetry publish
33+
command: uv publish
3834

3935
workflows:
4036
build-and-publish:

beanhub_cli/api_helpers.py

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,3 @@ def callee(*args, **kwargs):
3131
return callee
3232

3333
return decorator
34-
35-
36-
def make_auth_client(base_url: str, token: str) -> "AuthenticatedClient":
37-
from .internal_api.client import AuthenticatedClient
38-
39-
return AuthenticatedClient(
40-
base_url=base_url, prefix="", auth_header_name="access-token", token=token
41-
)
Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,14 @@
33
import sys
44
import typing
55

6-
from ..api_helpers import make_auth_client
7-
from ..config import load_config
6+
from .config import load_config
7+
from .internal_api.client import AuthenticatedClient
88

99
logger = logging.getLogger(__name__)
1010

1111

1212
@dataclasses.dataclass
13-
class ConnectConfig:
13+
class AuthConfig:
1414
token: str
1515
username: str
1616
repo: str
@@ -23,7 +23,7 @@ def parse_repo(repo: str | None) -> typing.Tuple[str | None, str | None]:
2323

2424

2525
# TODO: maybe extract this part to a shared env for connect command?
26-
def ensure_config(api_base_url: str, repo: str | None) -> ConnectConfig:
26+
def ensure_auth_config(api_base_url: str, repo: str | None) -> AuthConfig:
2727
config = load_config()
2828
if config is None or config.access_token is None:
2929
logger.error(
@@ -35,7 +35,7 @@ def ensure_config(api_base_url: str, repo: str | None) -> ConnectConfig:
3535
"No repo provided, try to determine which repo to use automatically ..."
3636
)
3737

38-
from ..internal_api.api.repo import list_repo
38+
from .internal_api.api.repo import list_repo
3939

4040
with make_auth_client(
4141
base_url=api_base_url, token=config.access_token.token
@@ -63,8 +63,14 @@ def ensure_config(api_base_url: str, repo: str | None) -> ConnectConfig:
6363
username, repo_name = parse_repo(
6464
repo if repo is not None else config.repo.default
6565
)
66-
return ConnectConfig(
66+
return AuthConfig(
6767
token=config.access_token.token,
6868
username=username,
6969
repo=repo_name,
7070
)
71+
72+
73+
def make_auth_client(base_url: str, token: str) -> AuthenticatedClient:
74+
return AuthenticatedClient(
75+
base_url=base_url, prefix="", auth_header_name="access-token", token=token
76+
)

beanhub_cli/connect/file_io.py

Lines changed: 0 additions & 12 deletions
This file was deleted.

beanhub_cli/connect/main.py

Lines changed: 27 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -7,21 +7,35 @@
77
import time
88

99
import click
10+
import httpx
1011
import rich
12+
from nacl.encoding import URLSafeBase64Encoder
13+
from nacl.public import PrivateKey
14+
from nacl.public import SealedBox
1115
from rich import box
1216
from rich.markup import escape
1317
from rich.padding import Padding
1418
from rich.table import Table
1519

1620
from ..api_helpers import handle_api_exception
17-
from ..api_helpers import make_auth_client
21+
from ..auth import AuthConfig
22+
from ..auth import ensure_auth_config
23+
from ..auth import make_auth_client
1824
from ..environment import Environment
1925
from ..environment import pass_env
20-
from ..utils import check_imports
21-
from ..utils import ExtraDepsSet
26+
from ..internal_api.api.connect import create_dump_request
27+
from ..internal_api.api.connect import create_sync_batch
28+
from ..internal_api.api.connect import get_dump_request
29+
from ..internal_api.api.connect import get_sync_batch
30+
from ..internal_api.models import CreateDumpRequestRequest
31+
from ..internal_api.models import CreateDumpRequestResponse
32+
from ..internal_api.models import CreateSyncBatchResponse
33+
from ..internal_api.models import DumpRequestState
34+
from ..internal_api.models import GetDumpRequestResponse
35+
from ..internal_api.models import GetSyncBatchResponse
36+
from ..internal_api.models import HTTPValidationError
37+
from ..internal_api.models import PlaidItemSyncState
2238
from .cli import cli
23-
from .config import ConnectConfig
24-
from .config import ensure_config
2539

2640
logger = logging.getLogger(__name__)
2741

@@ -30,14 +44,7 @@
3044
SPOOLED_FILE_MAX_SIZE = 1024 * 1024 * 5
3145

3246

33-
def run_sync(env: Environment, config: ConnectConfig):
34-
from ..internal_api.api.connect import create_sync_batch
35-
from ..internal_api.api.connect import get_sync_batch
36-
from ..internal_api.models import CreateSyncBatchResponse
37-
from ..internal_api.models import GetSyncBatchResponse
38-
from ..internal_api.models import HTTPValidationError
39-
from ..internal_api.models import PlaidItemSyncState
40-
47+
def run_sync(env: Environment, config: AuthConfig):
4148
GOOD_TERMINAL_SYNC_STATES = frozenset(
4249
[
4350
PlaidItemSyncState.IMPORT_COMPLETE,
@@ -147,10 +154,9 @@ def run_sync(env: Environment, config: ConnectConfig):
147154
help='Which repository to run sync on, in "<username>/<repo_name>" format',
148155
)
149156
@pass_env
150-
@check_imports(ExtraDepsSet.LOGIN, logger)
151157
@handle_api_exception(logger)
152158
def sync(env: Environment, repo: str | None):
153-
config = ensure_config(api_base_url=env.api_base_url, repo=repo)
159+
config = ensure_auth_config(api_base_url=env.api_base_url, repo=repo)
154160
run_sync(env, config)
155161
env.logger.info("done")
156162

@@ -162,7 +168,7 @@ def sync(env: Environment, repo: str | None):
162168
"-r",
163169
"--repo",
164170
type=str,
165-
help='Which repository to run sync on, in "<username>/<repo_name>" format',
171+
help='Which repository to run dump on, in "<username>/<repo_name>" format',
166172
)
167173
@click.option(
168174
"-s",
@@ -183,7 +189,6 @@ def sync(env: Environment, repo: str | None):
183189
help="Allow unsafe tar extraction, mostly for Python < 3.11",
184190
)
185191
@pass_env
186-
@check_imports(ExtraDepsSet.CONNECT, logger)
187192
@handle_api_exception(logger)
188193
def dump(
189194
env: Environment,
@@ -192,24 +197,13 @@ def dump(
192197
output_accounts: str | None,
193198
unsafe_tar_extract: bool,
194199
):
195-
import httpx
196-
from ..internal_api.api.connect import create_dump_request
197-
from ..internal_api.api.connect import get_dump_request
198-
from ..internal_api.models import CreateDumpRequestRequest
199-
from ..internal_api.models import CreateDumpRequestResponse
200-
from ..internal_api.models import DumpRequestState
201-
from ..internal_api.models import GetDumpRequestResponse
202-
from nacl.encoding import URLSafeBase64Encoder
203-
from nacl.public import PrivateKey
204-
from nacl.public import SealedBox
205-
206200
if not hasattr(tarfile, "data_filter") and not unsafe_tar_extract:
207201
logger.error(
208202
"You need to use Python >= 3.11 in order to safely unpack the downloaded tar file, or you need to pass "
209203
"in --unsafe-tar-extract argument to allow unsafe tar file extracting"
210204
)
211205
sys.exit(-1)
212-
config = ensure_config(api_base_url=env.api_base_url, repo=repo)
206+
config = ensure_auth_config(api_base_url=env.api_base_url, repo=repo)
213207
if sync:
214208
run_sync(env, config)
215209

@@ -270,8 +264,8 @@ def dump(
270264
logger.info("Decrypting downloaded file ...")
271265

272266
# delay import for testing purpose
273-
from .encryption import decrypt_file
274-
from .file_io import extract_tar
267+
from ..encryption import decrypt_file
268+
from ..file_io import extract_tar
275269

276270
decrypt_file(
277271
input_file=encrypted_file, output_file=decrypted_file, key=key, iv=iv
@@ -298,8 +292,8 @@ def dump(
298292
)
299293

300294
# delay import for testing purpose
301-
from .encryption import decrypt_file
302-
from .file_io import extract_tar
295+
from ..encryption import decrypt_file
296+
from ..file_io import extract_tar
303297

304298
decrypt_file(
305299
input_file=encrypted_file, output_file=decrypted_file, key=key, iv=iv
Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import io
22

3+
from cryptography.hazmat.primitives import padding
4+
from cryptography.hazmat.primitives.ciphers import algorithms
5+
from cryptography.hazmat.primitives.ciphers import Cipher
6+
from cryptography.hazmat.primitives.ciphers import modes
7+
38

49
def decrypt_file(
510
input_file: io.BytesIO, output_file: io.BytesIO, iv: bytes, key: bytes
611
):
7-
from cryptography.hazmat.primitives import padding
8-
from cryptography.hazmat.primitives.ciphers import algorithms
9-
from cryptography.hazmat.primitives.ciphers import Cipher
10-
from cryptography.hazmat.primitives.ciphers import modes
11-
1212
cipher = Cipher(algorithms.AES256(key), modes.CBC(iv))
1313
decryptor = cipher.decryptor()
1414
padder = padding.PKCS7(128).unpadder()

beanhub_cli/file_io.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import io
2+
import logging
3+
import pathlib
4+
import sys
5+
import tarfile
6+
7+
8+
def extract_tar(input_file: io.BytesIO, logger: logging.Logger):
9+
with tarfile.open(fileobj=input_file, mode="r:gz") as tar_file:
10+
if hasattr(tarfile, "data_filter"):
11+
tar_file.extractall(filter="data")
12+
else:
13+
logger.warning("Performing unsafe tar file extracting")
14+
tar_file.extractall()
15+
16+
17+
def extract_inbox_tar(
18+
input_file: io.BytesIO,
19+
email_output_paths: dict[str, pathlib.Path],
20+
workdir_path: pathlib.Path,
21+
unsafe_tar_extract: bool,
22+
logger: logging.Logger,
23+
):
24+
with tarfile.open(fileobj=input_file, mode="r:gz") as tar_file:
25+
for member in tar_file:
26+
if not member.isreg():
27+
continue
28+
member_path = pathlib.PurePosixPath(member.name)
29+
email_id = member_path.stem
30+
output_path = email_output_paths.get(email_id)
31+
if output_path is None:
32+
logger.error("Cannot find output path for email %s", email_id)
33+
sys.exit(-1)
34+
output_path.parent.mkdir(exist_ok=True, parents=True)
35+
logger.info(
36+
"Writing email [green]%s[/] to [green]%s[/]",
37+
email_id,
38+
output_path,
39+
extra={"markup": True, "highlighter": None},
40+
)
41+
full_output_path = workdir_path / output_path
42+
has_data_filter = hasattr(tarfile, "data_filter")
43+
if not has_data_filter and not unsafe_tar_extract:
44+
logger.error(
45+
"You need to use Python >= 3.11 in order to safely unpack the downloaded tar file, or you need to pass "
46+
"in --unsafe-tar-extract argument to allow unsafe tar file extracting"
47+
)
48+
sys.exit(-1)
49+
member.name = output_path.name
50+
tar_file.extract(
51+
member,
52+
full_output_path.parent,
53+
set_attrs=False,
54+
filter="data" if has_data_filter else None,
55+
)

beanhub_cli/import_cli.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
"--workdir",
4747
type=click.Path(exists=True, dir_okay=True, file_okay=False),
4848
default=str(pathlib.Path.cwd()),
49-
help="The beanhub project path to work on",
49+
help="The BeanHub project path to work on",
5050
)
5151
@click.option(
5252
"-b",
@@ -111,7 +111,7 @@ def main(
111111
env.logger.info("Skipped %s transactions", len(unprocessed_txns))
112112

113113
beanfile_path = (workdir_path / pathlib.Path(beanfile)).resolve()
114-
if workdir_path.resolve().absolute() not in beanfile_path.absolute().parents:
114+
if not beanfile_path.is_relative_to(workdir_path.resolve()):
115115
env.logger.error(
116116
"The provided beanfile path %s is not a sub-path of workdir %s",
117117
beanfile_path,

beanhub_cli/inbox/__init__.py

Whitespace-only changes.

beanhub_cli/inbox/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from ..aliase import AliasedGroup
2+
from ..cli import cli as root_cli
3+
4+
5+
@root_cli.group(
6+
name="inbox",
7+
help="BeanHub inbox features, such as dump (login required) or extract.",
8+
cls=AliasedGroup,
9+
)
10+
def cli():
11+
pass

0 commit comments

Comments
 (0)