Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
9b8c69b
feat: upload and parse account balance
CalvinChanCan Mar 2, 2024
813adca
chore: fix formatting
CalvinChanCan Mar 2, 2024
1593b90
chore: update README.md
CalvinChanCan Mar 2, 2024
9bf0010
feat: add delete_account
CalvinChanCan Feb 14, 2024
cbff400
feat: upload and parse account balance
CalvinChanCan Mar 2, 2024
1d2eb40
chore: fix formatting
CalvinChanCan Mar 2, 2024
a4c614c
chore: update README.md
CalvinChanCan Mar 2, 2024
76ebcde
Merge remote-tracking branch 'origin/feat-upload-balance-history' int…
CalvinChanCan Mar 7, 2024
00c6760
remove delete_account from other branch
CalvinChanCan Mar 7, 2024
7ad7961
feat: add delete_account
CalvinChanCan Feb 14, 2024
967447b
fix merge conflicts
CalvinChanCan Mar 8, 2024
ad421a8
chore: update README.md
CalvinChanCan Mar 2, 2024
d6f5180
remove delete_account from other branch
CalvinChanCan Mar 7, 2024
14beaf3
Merge remote-tracking branch 'origin/feat-upload-balance-history' int…
CalvinChanCan Mar 8, 2024
1d07ae0
add back delete_account
CalvinChanCan Mar 8, 2024
8b37717
refactor: keep existing upload_account_balance_history
CalvinChanCan Mar 9, 2024
76d25b2
fix: add back delete_account to readme
CalvinChanCan Mar 9, 2024
e7acd66
chore: rename _parse_upload_balance_history_session to _initiate_uplo…
CalvinChanCan Mar 9, 2024
1fe7987
chore: rename get_upload_balance_history_session to _is_upload_balanc…
CalvinChanCan Mar 9, 2024
ee959a3
refactor: define constants for delay and timeout to use consistently …
CalvinChanCan Mar 9, 2024
7f68c9c
chore: move description into body of docblock
CalvinChanCan Mar 9, 2024
44711ad
chore: lint
CalvinChanCan Mar 9, 2024
4e2d624
fix: update return type from str to bool for upload_account_balance_h…
CalvinChanCan Mar 10, 2024
53b6abc
refactor: replace csv_content str with dataclass
CalvinChanCan Mar 10, 2024
86a0311
chore: add docstring
CalvinChanCan Mar 10, 2024
ee41dc6
Update monarchmoney/monarchmoney.py
hammem Jan 19, 2025
9394a97
Update monarchmoney/monarchmoney.py
hammem Jan 19, 2025
508f9bf
fix: persist long-lived session tokens (Fixes #139)
Oct 10, 2025
2d4f1b4
Update README.md
bradleyseanf Jan 3, 2026
89a9ce9
Create assets folder
bradleyseanf Jan 3, 2026
b6bbf50
Update readme.Md with auth correction notice
Jan 3, 2026
d97de7f
Merge branch 'hammem:main' into fix/session-persistence-#139
bradleyseanf Jan 11, 2026
34e440d
Fix get_budgets query for legacy goals removal
bradleyseanf Jan 11, 2026
b6732d6
Merge remote-tracking branch 'origin/fix/session-persistence-#139' in…
bradleyseanf Jan 11, 2026
a1703a3
Merge remote-tracking branch 'origin/fix/get_budgets()-query' into ma…
bradleyseanf Jan 11, 2026
d317b9a
updating gql endpoint
bradleyf-2025 Jan 15, 2026
3989f3e
Release v1.0.0
Jan 17, 2026
147b1aa
Update README
Jan 17, 2026
88e3dc7
Merge branch 'community-dev' into feat-upload-balance-history
CalvinChanCan Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ As of writing this README, the following methods are supported:
- `set_budget_amount` - sets a budget's value to the given amount (date allowed, will only apply to month specified by default). A zero amount value will "unset" or "clear" the budget for the given category.
- `create_manual_account` - creates a new manual account
- `delete_account` - deletes an account by the provided account id
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please pull this branch off of your other commits in the other PR, so it doesn't accidentally introduce those changes simultaneously.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

- `upload_account_balance_history` - uploads account history csv file for a given account
- `upload_and_parse_balance_history` - uploads and parses account history csv file for a given account
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd vote for keeping the existing method rather than make a breaking change. The fact that the API now requires us to poll for the result is an implementation detail.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @andrecloutier. That's a fair point. I did a bunch of refactoring to keep the existing method upload_account_balance_history.


# Contributing

Expand Down
111 changes: 108 additions & 3 deletions monarchmoney/monarchmoney.py
Original file line number Diff line number Diff line change
Expand Up @@ -2392,14 +2392,54 @@ async def set_budget_amount(
graphql_query=query,
)

async def upload_and_parse_balance_history(
self,
account_id: str,
csv_content,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs a typehint

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To keep upload_account_balance_history, I've removed upload_and_parse_balance_history so this isn't necessary anymore.

timeout: int = 300,
delay: int = 10,
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

define these as constants, so it can be consistent across the API

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

) -> bool:
"""
Uploads and parses the balance history, updating the account balance on monarch money

:param account_id: The account ID to apply the history to.
:param csv_content: CSV representation of the balance history. Headers are Date, Amount, and Account Name.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should probably be something more structured than a str? If there are specific columns and column types/formats needed, perhaps using a List[Dict[str, Any]]? Alternatively, use either Python's built-in CSV parsing library or, maybe, Pandas.

nit: move the description of the CSV format required to the body of the docblock

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're going this far to normalize the input, I'd vote for making the list value a class (or DataClass/NamedTuple). This way the caller doesn't need to worry about what the magic key names are.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that a dataclass would help the caller with the proper input. I've refactor it to use a dataclass. A second review over this would be appreciated!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Personally, I'm fine with providing the CSV as a string. This gives the user the most flexibility to use the tools of their choice to build the CSV. If we require a normalized input, then they'll have to use a CSV parser to parse the input, only for it to generate a CSV once again.

Optionally, we could provide users with a CSV builder kind of class if we want to help them along. IMO this could be an improvement made outside of this PR. That's @hammem 's call :)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the good discussion on this one! Yeah, it's not ideal no matter which route you take. If only we knew why @monarchmoney decided to not use a GraphQL endpoint for uploading this data...

The option to go with a dataclass is great and probably something to do elsewhere.

My main concern with a raw CSV string is it makes debugging a huge pain for end users, as we can't guide or hint where things might be off.

But, I've been holding this up for a while, no need to keep holding it up for that.

Appreciate everyone's patience!

:param timeout: The number of seconds to wait before timing out
:param delay: The number of seconds to wait for each check on whether parsing is completed
"""

session_key = await self.upload_account_balance_history(
account_id=account_id, csv_content=csv_content
)

response = await self.parse_upload_balance_history_session(
session_key=session_key
)

is_completed = (
response["parseBalanceHistory"]["uploadBalanceHistorySession"]["status"]
== "completed"
)

start = time.time()

while not is_completed and (time.time() <= (start + timeout)):
await asyncio.sleep(delay)

is_completed = (await self.get_upload_balance_history_session(session_key))[
"uploadBalanceHistorySession"
]["status"]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this is missing a == "completed" check.

Though, I believe a better approach would be to check whether the processing is still "pending" so that this code short circuits if the upload fails.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is missing the check! Thanks for catching that!

The possible statuses that I've found so far are: "created", "started", and "completed". After calling _initiate_upload_balance_history_session, it usually returns the "created" status. Then _is_upload_balance_history_complete can either return the "started" or "completed" statuses.

If _is_upload_balance_history_complete can return "created", it may lead to a premature completed check. Though I don't think that's likely.

@hammem , thoughts on whether we should check for "started" vs "completed"? I could see the better case being checking for "started" but worried about possible side effects that may cause.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I was expecting a failed state if an invalid csv is provided. In that case, looks like the following error is thrown. Which I think is sufficient. Please disregard the suggestion!

gql.transport.exceptions.TransportQueryError: {'message': "Something went wrong while processing: ['parseBalanceHistory']

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CalvinChanCan , after looking through the flow, I think you've picked the better option. the other can introduce more complicated handling for end-users.


return is_completed

async def upload_account_balance_history(
self, account_id: str, csv_content: str
) -> None:
) -> str:
"""
Uploads the account balance history csv for a given account.
Uploads the account balance history CSV for a specified account.

:param account_id: The account ID to apply the history to.
:param csv_content: CSV representation of the balance history.
:param csv_content: CSV representation of the balance history. Headers: Date, Amount, and Account Name.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: move this description into the body of the docblock, instead of here, where it's challenging to fit.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

"""
if not account_id or not csv_content:
raise RequestFailedException("account_id and csv_content cannot be empty")
Expand All @@ -2417,6 +2457,71 @@ async def upload_account_balance_history(
if resp.status != 200:
raise RequestFailedException(f"HTTP Code {resp.status}: {resp.reason}")

response = await resp.json()
session_key = response["session_key"]
return session_key
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return the entire JSON block, for consistency with the rest of the API.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I refactor this quite a bit since the initial review so I've added _upload_form_data as a way to generalize the uploading form data (I'm hoping to use this later to bulk add transactions from a csv). Anyway, this should now return the whole response.


async def parse_upload_balance_history_session(self, session_key: str) -> dict:
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit

Suggested change
async def parse_upload_balance_history_session(self, session_key: str) -> dict:
async def initiate_upload_balance_history_session(self, session_key: str) -> dict:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

"""
Triggers parsing of the uploaded balance history CSV file.

:param session_key: The session key for the uploaded file.
"""

query = gql(
"""
mutation Web_ParseUploadBalanceHistorySession($input: ParseBalanceHistoryInput!) {
parseBalanceHistory(input: $input) {
uploadBalanceHistorySession {
...UploadBalanceHistorySessionFields
__typename
}
__typename
}
}
fragment UploadBalanceHistorySessionFields on UploadBalanceHistorySession {
sessionKey
status
__typename
}
"""
)

variables = {"input": {"sessionKey": session_key}}

return await self.gql_call(
"Web_ParseUploadBalanceHistorySession", query, variables
)

async def get_upload_balance_history_session(self, session_key: str):
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for consistency with similar methods in this API

Suggested change
async def get_upload_balance_history_session(self, session_key: str):
async def is_upload_balance_history_complete(self, session_key: str) -> bool:

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

"""
Retrieves the status of the upload balance history session.

:param session_key: The session key for the uploaded file.
"""

query = gql(
"""
query Web_GetUploadBalanceHistorySession($sessionKey: String!) {
uploadBalanceHistorySession(sessionKey: $sessionKey) {
...UploadBalanceHistorySessionFields
__typename
}
}
fragment UploadBalanceHistorySessionFields on UploadBalanceHistorySession {
sessionKey
status
__typename
}
"""
)

variables = {"sessionKey": session_key}

return await self.gql_call(
"Web_GetUploadBalanceHistorySession", query, variables
)

async def get_recurring_transactions(
self,
start_date: Optional[str] = None,
Expand Down