diff --git a/Dockerfile b/Dockerfile index fb4fc00..477399a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -24,16 +24,6 @@ RUN chmod +x /entrypoint.sh EXPOSE 8000 ENTRYPOINT ["/entrypoint.sh"] -# ---- ADJUST THIS LINE to the app's real entry point ---- -# Flask (app object named `app` in app.py): -CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "app:app"] -# -# FastAPI (app object named `app` in main.py) — swap the CMD for: -# RUN pip install "uvicorn[standard]" -# CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] -# -# Django (project package `mysite`): -# CMD ["gunicorn", "--bind", "0.0.0.0:8000", "mysite.wsgi:application"] -# -# Streamlit: -# CMD ["streamlit", "run", "app.py", "--server.port=8000", "--server.address=0.0.0.0"] +# Single worker + threads: the app stores state in JSON/SQLite files on the +# volume, so multiple processes would race. Threads handle light concurrency. +CMD ["gunicorn", "--bind", "0.0.0.0:8000", "--workers", "1", "--threads", "4", "app:app"] diff --git a/app.py b/app.py index 5f8535d..5ef2a79 100644 --- a/app.py +++ b/app.py @@ -5,6 +5,7 @@ import pdfplumber import re from datetime import datetime, date import calendar as cal_module +from storage import load_json, save_json from categories import CATEGORIES, CATEGORY_COLORS from bills import load_bills, save_bills, bills_for_month, calendar_grid, get_week_num from sinking import (load_funds, save_funds, FUND_COLORS, monthly_needed, @@ -57,14 +58,10 @@ def money_filter(value): TAX_FILE = os.path.join(os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")), "taxes.json") def load_taxes(): - if os.path.exists(TAX_FILE): - with open(TAX_FILE) as f: - return json.load(f) - return {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []} + return load_json(TAX_FILE, {"rates": {"se_tax": 15.3, "federal": 22.0, "wi_state": 5.3}, "set_aside": []}) def save_taxes(data): - with open(TAX_FILE, "w") as f: - json.dump(data, f, indent=2) + save_json(TAX_FILE, data) STANDARD_DEDUCTION_MFJ = 30000 # 2025 married filing jointly @@ -177,27 +174,11 @@ NET_WORTH_FILE = os.path.join(DATA_DIR, "net_worth_accounts.json") def load_nw_accounts(): - if os.path.exists(NET_WORTH_FILE): - with open(NET_WORTH_FILE) as f: - return json.load(f) - return [] + return load_json(NET_WORTH_FILE, []) def save_nw_accounts(accounts): - with open(NET_WORTH_FILE, "w") as f: - json.dump(accounts, f, indent=2) - - -def load_json(path, default): - if os.path.exists(path): - with open(path) as f: - return json.load(f) - return default - - -def save_json(path, data): - with open(path, "w") as f: - json.dump(data, f, indent=2) + save_json(NET_WORTH_FILE, accounts) def get_transactions(): diff --git a/assets.py b/assets.py index b9eb4d7..ef1ac62 100644 --- a/assets.py +++ b/assets.py @@ -1,5 +1,7 @@ +import copy import json import os +from storage import load_json, save_json DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) ASSETS_FILE = os.path.join(DATA_DIR, "assets.json") @@ -44,20 +46,19 @@ ASSET_CATEGORIES = ["Investments", "Real Estate", "Equipment", "Other Valuables" def load_assets(): - if os.path.exists(ASSETS_FILE): - with open(ASSETS_FILE) as f: - data = json.load(f) - # migrate old format if needed - if "other_assets" not in data: - data["other_assets"] = [] - return data - save_assets({"personal": DEFAULT_CARS, "flips": [], "other_assets": []}) - return {"personal": DEFAULT_CARS, "flips": [], "other_assets": []} + data = load_json(ASSETS_FILE, None) + if data is None: + data = {"personal": copy.deepcopy(DEFAULT_CARS), "flips": [], "other_assets": []} + save_assets(data) + return data + # migrate old format if needed + if "other_assets" not in data: + data["other_assets"] = [] + return data def save_assets(data): - with open(ASSETS_FILE, "w") as f: - json.dump(data, f, indent=2) + save_json(ASSETS_FILE, data) def car_display_name(car): diff --git a/bills.py b/bills.py index dfd23f5..ec4d2c0 100644 --- a/bills.py +++ b/bills.py @@ -2,21 +2,18 @@ import json import os from datetime import date, timedelta import calendar +from storage import load_json, save_json DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) BILLS_FILE = os.path.join(DATA_DIR, "bills.json") def load_bills(): - if os.path.exists(BILLS_FILE): - with open(BILLS_FILE) as f: - return json.load(f) - return [] + return load_json(BILLS_FILE, []) def save_bills(bills): - with open(BILLS_FILE, "w") as f: - json.dump(bills, f, indent=2) + save_json(BILLS_FILE, bills) def get_week_label(day): diff --git a/debts.py b/debts.py index 1d1fde4..b6e7727 100644 --- a/debts.py +++ b/debts.py @@ -2,6 +2,7 @@ import json import os from datetime import date import math +from storage import load_json, save_json DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) DEBTS_FILE = os.path.join(DATA_DIR, "debts.json") @@ -116,27 +117,19 @@ OWNER_COLORS = { def load_debts(): - if os.path.exists(DEBTS_FILE): - with open(DEBTS_FILE) as f: - return json.load(f) - return [] + return load_json(DEBTS_FILE, []) def save_debts(debts): - with open(DEBTS_FILE, "w") as f: - json.dump(debts, f, indent=2) + save_json(DEBTS_FILE, debts) def load_debt_log(): - if os.path.exists(DEBT_LOG_FILE): - with open(DEBT_LOG_FILE) as f: - return json.load(f) - return {} + return load_json(DEBT_LOG_FILE, {}) def save_debt_log(log): - with open(DEBT_LOG_FILE, "w") as f: - json.dump(log, f, indent=2) + save_json(DEBT_LOG_FILE, log) def month_key(year, month): diff --git a/income.py b/income.py index 6b79c4f..63e89ea 100644 --- a/income.py +++ b/income.py @@ -1,6 +1,7 @@ import json import os from datetime import date +from storage import load_json, save_json DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) INCOME_FILE = os.path.join(DATA_DIR, "income_log.json") @@ -21,15 +22,11 @@ OWNER_COLORS = { def load_income_log(): - if os.path.exists(INCOME_FILE): - with open(INCOME_FILE) as f: - return json.load(f) - return [] + return load_json(INCOME_FILE, []) def save_income_log(log): - with open(INCOME_FILE, "w") as f: - json.dump(log, f, indent=2) + save_json(INCOME_FILE, log) def month_key(year, month): diff --git a/mortgage.py b/mortgage.py index 6e3d5ae..4e3355a 100644 --- a/mortgage.py +++ b/mortgage.py @@ -3,21 +3,18 @@ import os from datetime import date from dateutil.relativedelta import relativedelta import math +from storage import load_json, save_json DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) MORTGAGE_FILE = os.path.join(DATA_DIR, "mortgage.json") def load_mortgage(): - if os.path.exists(MORTGAGE_FILE): - with open(MORTGAGE_FILE) as f: - return json.load(f) - return {} + return load_json(MORTGAGE_FILE, {}) def save_mortgage(data): - with open(MORTGAGE_FILE, "w") as f: - json.dump(data, f, indent=2) + save_json(MORTGAGE_FILE, data) def monthly_payment(principal, annual_rate, term_years): diff --git a/sinking.py b/sinking.py index 6e2b762..1b13622 100644 --- a/sinking.py +++ b/sinking.py @@ -1,6 +1,8 @@ +import copy import json import os from datetime import date +from storage import load_json, save_json DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) SINKING_FILE = os.path.join(DATA_DIR, "sinking_funds.json") @@ -25,17 +27,17 @@ DEFAULT_FUNDS = [ def load_funds(): - if os.path.exists(SINKING_FILE): - with open(SINKING_FILE) as f: - return json.load(f) - # First run — seed with defaults - save_funds(DEFAULT_FUNDS) - return DEFAULT_FUNDS + funds = load_json(SINKING_FILE, None) + if funds is None: + # First run — seed with a copy of the defaults (never hand out the + # module-level list, which callers mutate in place). + funds = copy.deepcopy(DEFAULT_FUNDS) + save_funds(funds) + return funds def save_funds(funds): - with open(SINKING_FILE, "w") as f: - json.dump(funds, f, indent=2) + save_json(SINKING_FILE, funds) def months_until(target_date_str): diff --git a/storage.py b/storage.py new file mode 100644 index 0000000..06d4748 --- /dev/null +++ b/storage.py @@ -0,0 +1,50 @@ +"""Crash-safe JSON persistence shared across all data modules. + +- load_json: returns `default` if the file is missing, empty, or corrupt + (e.g. a partial/interrupted write). A corrupt file is moved aside to + `.corrupt` so it can be inspected rather than silently overwritten. +- save_json: atomic write — serialize to a temp file in the same directory, + fsync, then os.replace() (atomic on POSIX). An interrupted write can never + leave a truncated or empty target file. + +This is the durability backbone for the app's JSON storage (issues #11/#12). +""" +import json +import os +import tempfile + +DATA_DIR = os.environ.get("DATA_DIR", os.path.join(os.path.dirname(__file__), "data")) + + +def load_json(path, default): + if not os.path.exists(path): + return default + try: + with open(path) as f: + return json.load(f) + except (json.JSONDecodeError, ValueError): + # Corrupt/partial file — preserve it for inspection, fall back to default + # instead of crashing the request. + try: + os.replace(path, path + ".corrupt") + except OSError: + pass + return default + + +def save_json(path, data): + directory = os.path.dirname(path) or "." + os.makedirs(directory, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=directory, prefix=".tmp-", suffix=".json") + try: + with os.fdopen(fd, "w") as f: + json.dump(data, f, indent=2) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp, path) + except BaseException: + try: + os.remove(tmp) + except OSError: + pass + raise diff --git a/templates/base.html b/templates/base.html index b5b83e6..b69fe80 100644 --- a/templates/base.html +++ b/templates/base.html @@ -23,6 +23,7 @@ Debt Net Worth Spending Report + 💡