From 1fcaec8daf39f17a61131034f895cbfac423ddfc Mon Sep 17 00:00:00 2001 From: Mathias Scheider Date: Fri, 16 Oct 2026 22:51:11 +0200 Subject: [PATCH] mypy black ruff --- .coverage | Bin 86016 -> 86016 bytes AGENTS.md | 12 ++ pyproject.toml | 5 + src/domain_utils.py | 93 +++++++------ src/models/category.py | 6 +- tests/test_category_edge_cases.py | 29 ++-- tests/test_db_utils_update_path.py | 159 +++++++++++++--------- tests/test_domain_utils_budget_overlap.py | 78 +++++++---- tests/test_models_common.py | 40 +++--- tests/test_models_init.py | 17 ++- 10 files changed, 275 insertions(+), 164 deletions(-) diff --git a/.coverage b/.coverage index d22ad8060f26e264dfa57eaf06e0ddc7e8945c37..fc1003fc69531a601ce49aad94ca985623bd316b 100644 GIT binary patch delta 15007 zcmYkCcYqevx&P;!^OonGcix#@%yMXlGq%N>5ZE1@vXhduQVT{x4v4V;s z5Wv_#ArT@`lp741=r5WZbEEgCSE3{uTWrZS(Y?>vXPy!M`g~_*8$0uzb3V^rw<~+y zuI!Uv929*Ie|hjPPRrYk+vWR)VK~K{CTz?|U4z~|uDa#g9RB*< zQ_5@IS{C`DR;{GT+_I%Cr2Mf2W&oLI&gEo|Ih&PP=1fLrn5C4efZs%+AWKpvN3E4B zrD$ePNNdFkY4C~V%S97&eZBaOdD$}YE%VZ);@`|mmWY2bFJ3IZVXmta|I55^q4;OY zP2wLZKPcO#GR7 z!US=edE7YhZZYRI-yYVun6Mx(r{Wl&)l`EIL_Rqi+C>O)8bj?@~+~SDR&Xa zQZ5(IFn8`Oe!<+KgE*S<8F7TzlUDoo;%Pq7{xk7&=C*Cc&r<$C9A-Y}9C3)bb!+id z${&d*nOn6I2bo*65C@pc%ES}Qt}7lddel!=sU#lb6Q!@jqs*2i9$_vP#ZQ?5#KX+_ zyx7m2%ZYu=nT*)Wtd#f(vy@^_Qc3vfcPEF7n>O9`{D&8KS6m?;;6ocXiXF@wHi-L~ z8ym%a%xl+*dsA)_+f&{ywlOy}hbbp6iUD4EIRsm>x>M}V zoguiEuu~@pHWPO22*D=84jmx)Az{0A5L`ppx-|q>6Sisv!BvEo1;Les#Ucb(B={8s z8wmj*xIEk3W_B(I!3H{<%|dV)VI~8?4+y2q1(zZg3bq+sf*@tdc+hyz4Av*%0pkHP zSeJzRjr+}@F$wn>_Z5P*I*5{SUy~VJ%qN=Gm|n0ZN!yHVX3)T)X`LCYPQpFLJ!Y^f z36T+*!OA2AMqmajI5c@?usjLA;hRBy60SF{H-lwK_>u7=2$o_HqU3oV1WSmwZiQem z;g&5BEF!%2S_tY0H*bbuA>pP?5Y!T0eKiCN61)+D`Go7&Loko9u@QnA!nJE5m`k{3 z4Fq!#u}w=24G_$x!>d*^qwOt^3%1d|9CEC4TOpY11GWMu(>zg9Epetd&eh_pa?AsTD@&w<2pfh2gJ`i*w?A;rJjtRaAK?lNK zy&z~$*s~`D?Ff7HfS|3H@H-HkL)g7L1Z@brb%UTaVb`t@v?A1v)iS{|5VRodgh$H~ z#PV_pb$L02y1Z<{_U$1k5qfRgLSPZMX#+u#P)Akl@D<S$~BZ1Ab0t=TVtuR9ulB7Eq4-O?Hlfxn&*pFdwW zG#;Hk9emx-`21PZkAko384u$_=j(RXcm#Z1&4~E?`MQ`j;!$18i1;Y_x|9*|QS@~s zYdi+NE@Z?B6TsJXjEGOAugh5DaqyQB;sfe?)Wp`9G2l}XTcbvSPep7E9}Yehv322v z;8PJ>_}uzb#1=k(J{7T5{TldG#8wp^r6RVfs=%irwgwLdpNiPRXVs@7_N+?0LN_sf zZJG!KWg&1`Gh7irB)()}KzF0iRW$iWry3;8PJ>_}KbX z#1=lFJ{7Um`z`P%B{v&Bp*|I{)e8?(5nDa+Vk%;*Ctg5BY~ds9QxRL|o(nz|v4zj7 zPep8XI}QFQ`hM`S^{I%hE?vN6_*rM(!s@UQ(M5@@Lt}JTU;!^w?)Ud^+_}Qpo zi@Lq2VT-!Gs9}q`vZ!H;wY5h^m&A+GTVOb6oHw0UofFQ}&ZEvw=T0YbHaV9%E1dbx zRA;19;q-LcJGLY3uk8=*x9!*M7wu>41NI(!n|+hL)xO+ruFQXr%P{?UM@XXI#k+Uy1#T=>H5-DrN+|I(wx%7(y-D6rEaA*CDWSno%OkO#(L9w z)jDB4Z9Qu3wC*f)F0?4*;2Zb^PQx4UGCT)|U@vTg8(x8{u6oc~9} zp`URKT!zX&rwLb^@^qT6Gp>^;3iNMBI-V!l6F6J8NcIHImfey)fwO6gWKZC1+9dy* z6^nEACds0}x%%vH>I%uCz`5cU$)dp7xKXkwa5mm5*%LVHHcIvc&boDyJ%O`&on%ko ztX?f&Prhl-S+-2FC~y`pmMjXK#ZOBX1hL>6gbtxWg-fsQ$0koCvd8(C3^y=s!FmaaC-KX>C6zmx@BpU^L+BC^V!N$9TjeY`lZ+#BZV5DA;(buu-t__F$u6j~*@ADA=P$Nj3`h$dQtbf<3ZHvQe-{ zo{(%5?DNl;Y!vK1eIy$N`@Hic8wI;}Z%@X_e~R6!myDS4RtcHUJy!id~wWXUbRZjV#bNZOlBNd%wVpsmlvhHOHOBAvP4cx`GA~?-25Px>gwbaKCy72 zoXosnft^npIOe%?4@;kCEGj`-T%-E4_n6V>U^FpMACE1Ekl%J6;nLCxs7Abd< zWz5)@E;II}!`!~Tw3+cWTS^{!y_&pTzE?5~alX9NaK3Urbl!3P(|O6cr={q3*O=ec zZNIOJ7_{hk@lbJJ@!sOi#jV8+#Z|=x#i_*+#R0|c#Wux)`ET=6^R)Q~^OSkaJYeoN z?>1v|lX;0*Z_YI*nnTTgX1UqI%ohGx_*>!a!fSd1s&B4Ku654LHf6ueevo}T`}^#v?6cWJ*@v?a zWbepE+0EI@va7Ro*_qk#*`e7BvfaIG+iWQ-GylkZlzBJvM&>t}=QBUgJeqkZb9d&( z%+}1t%$iJH=Az8#Ol78Lrd`HTP3m*?zWS5;tvap_seS5Rb+g*4E?28nt(vArqGj)) z&QYfPR(>Y`EZ>kX$zRHYa*w=6-XJ&QfAv}a%($=Ki%aPmdyRc&toM}^_L{NYS?cf; z<0odU_m(>B(LwJnDeN(0y}zWe!;JL~Q-|HgZZpZN_b8toN%r+-lrv#@Y$$aEozEA=X<~{bg>!6MD~zCp_au<3=;q+g5$q z4aN;-tT(PY#71n!dh4o#o&m&q^QuE=gl4R_uR7>~K&&^gI_McdthcZ_=ovtqO9H-R z%{ZF`+^!Yk3)07KFIdj}5}hE`x{!j(gd0h{zVYms|o7 z*+Lh`01??jcO4$xOdqxmk8VnE9YjAQY+UC-bWOsI5M52U7V)YC*Ftn9A&w8CD+n79 zHzwEs(dC4z@Y)Rt;!(1NF4~0X2Xqu|LUbwN3OsrVB5pg}6?k}ka(D$q>j=>|M2&=~ z8=|!d);C~)Id~2I2Unlf^#97lOTSL*@Wn3BeI3=Z2ZJC>F8`cIwQf^5M4w#3vZg~3F6Ud z3F0U7rV^vQjiwOdY$TdYh;BBTM2JE*nn;KiHkv?)7B(7BI0127f)gMbONe8VXbd3= z*=RH&n%8JlGkPx0Ng~pP?#Po6kuG#cjDU!Ap^HK`x{y8&g={p85XU6Z&;(zDXb537 z9<5HW8loyf6tdA^!oi4x5*!RsW%4MYoQ*2zFbdgdV1ktp4Ir$*&(=RdJbFQb6%d_I zh?+L)M~Ip>>Pv{4Hqxz*cX>ZV-RT(jdXkCQJnEcy1Nl^OcHe@7wY0< zH|ju#(V<4Vw($xSr;#pgL=>lyu53h zi(`^V*EAkQ=^5#gMpT}HNOv?cTF*!~bfMsy5!JI}{Mz_+A!=@Cf#FRvqH=bO7mXLq zh|1Y9PU=AAti#Dd)LhQ^At&)esGA+*gmJ=*sGA+*1>*%X%ITk9tF`Bh=gf%8*)fh8 z$IM8Vvp(|+;}>S6%UOq``Z8V56!0=#&=iiEk*;VRju@q{$zu+)Gt`AHrRU&+Vuu;(PS>aI)Vnq+-?pkx%^fe$ zi$X{hk6IK$s(7@b5PH<{Xhk8Ujz=j9A$2?|Q3$Ey(TGAw9gjj3Lh5+*p%7BXqYj0T zIv#B(gw*jULm{M!M;8hqRXnOt2&v*dRG|=3$D;{_kUAbcD1_AUs6ip5jzUdP25K_mZ`Gk-<9>phw)bZ#&A*7B+?Fk`uJX%i(E9pyMd4^Q+=sY2$ig!FL z(vUhHjVFZE@hChYq>e}52_bbn>P`r$4w)3E{cazoe|+2_bbnN=^u=#S zP}eyk{xL#0P`A0}cOcYVj;QYl-QiB6;BkOE9$FfgkAHr?CpS`k2c1?C6 z?&8N~tFwKx<=K|meCA)7Pcwhc{1LbC$1{gB4`+5fb;oa{R*HGAtbKd+Qer{M+W$qJ zVs6(?{hGN=8}%Y{S(!SSa+CU1%AczLV76^_g1J;uFEE>?dOqc^)p1rjW%;~%j!)!r z>RIM&R{fG$DRnHd%w_7CMAo>*VdJp+#rLVHQ5{VaE*#X6G(BlNsh&;~E*;d*OZv|@ zQD3{bcsQVbmY#gVctRac)8oeD>QI_+$)KJ}(<8d7?y)cC17m?m6Lr~_%*Z|ql3 zq-meAPd#4Jv$f_gkCH<@mY&>e>{XAZ2{ng$Bu(f!)KAY!d(^{cr5$R2nsytz)xI>L z>ri{oO1srhFnRRlcN#m@p7bQ@4z>HN^q|_6CKMj(p){fKP&>~`52y#vN;}j8X+r6t zcBBcdhq_-Uulc7(|F7;#Ponow_a@T-Ek4xt^c0#8wJlA!?os!o2^T)IL* z51HJ{E>k>YLLs8q9-|RaTa$;%_13RfTR5z{L|vQmdc{K~cilS0LngOzo#G*r+t{dh z$mFhVR6JyI*REAOWOCQ6Q9NXF8`dZuGPw;6N)MTuM+)w$wd!*I!mAn-51CMqC>}Do zD_5!?@HHz|sY_E{sV+%*g<8+Ne7Rc3T)$j3rd+SqGA~=^sf#%*U8>eFFIlP@QeL7~ zGcR7ER;9dHtz=%bNUdP5t5?fYUZm<%u2ajH7cNvwn^{Y)+J$NfpQx=>i&LJZc#`BU zs8c*iau?Jpo+P;o7AT%1x%1~Mo+P0;Q9Mabn z=FCw%LPCL}c!cE6o}+k#IwA-O|_C>|lXLtaxnLUOBzC>|lX)zyke zNN!b?;t`TNxJuCo34i**gB4GZ+(F(T#UmuQa*(RvFI-uvc!cCuR4X1KxfPX)M@Vi( zh2jyCJ8+=l5t2J#fZ`F7+rPi!5fW+^)rUWSzkaHDf~5bp!R^yW@dyb`i|WM}^*T@S z2nk(_;t>+c7S)5V>E2y+XYSHPbxZj@)s?xtTy+RGpY{?a`6B zZCk}7B-Ad7M@Z;h6pxTlyeJ+ap?Og}LPGVTc!Y%RMd1jEZa@H)OpvhualOGKB=j$; z1z&{g4IUw(fKhI;_HdPx^mDU8~WrHuSGO}Ms_|NcHTZIS;<6Rxe~H)%pIBmeiTbWr{?O&+ehd8N1{sX+%jQKTZ=`8u`&# z=^^>Iv(iraVVcm>$PdzlqDKDptaP{hOPX-WBG2Gx(dcYHU^rhmL(e#Wa(?R^cMduG zoO_*{ovqFWXO*)6|9o$ZQ|0t^x;U*J({8fAv_HT<;QPIO%6`^9WIt>_aJbB^wLOT* zTi`MtV)7QaiieoAMH%j|W6~C7xT%gwTa@AEI3{gTR@MYDX^XP5Pa!64QHJ(1CT&rM zf7BV1wkSi98I!gsGrxvdYYVIoT)RU|-l7aQ)iJ5*GW>(@n6yQiQn^@bi$bByjJ34T z>up@No3Yjw&7rB$jJ3GXLEFAqs|y`)&2Gk8Ug)50U##_o4tV33af3D(I^lxdjJ3ni y0oUthtSyEP+VI6%W9WdZbu-o?LkC=}o3U0II%xM7Ynh<~-dtv^b%qYOQvW|QonwIj delta 15115 zcmYkDcYqev)y8M;eao5h°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 CategoryDb | None: """Find a CategoryDb in the database given a Pydantic Category object. - + Recursively finds parent categories to build the full hierarchy path. Returns None if any part of the hierarchy doesn't exist. """ @@ -38,16 +37,16 @@ def find_category_db(session: Session, category: Category | None) -> CategoryDb def set_category_budget(session: Session, category_budget: CategoryBudget): """Set or update a category budget, handling overlaps correctly. - + This function handles three cases: 1. No overlap: Add the new budget alongside existing ones 2. Partial overlap: Split the existing budget and add appropriate parts + new budget 3. Full overlap: Replace the existing budget entirely with the new one - + Args: session: SQLAlchemy session category_budget: The budget to set/update - + Raises: ValueError: If the category doesn't exist in the database """ @@ -72,46 +71,61 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): ) # Helper to check if two date ranges overlap - def _ranges_overlap(start1: date | None, end1: date | None, - start2: date | None, end2: date | None) -> bool: + def _ranges_overlap( + start1: date | None, end1: date | None, start2: date | None, end2: date | None + ) -> bool: """Check if two date ranges overlap.""" # Both completely unbounded - always overlap if start1 is None and end1 is None and start2 is None and end2 is None: return True - + # At least one range has both bounds None (truly unbounded) # It only overlaps with another truly unbounded range, not with bounded ranges if start1 is None and end1 is None or start2 is None and end2 is None: return False - + # Both ranges are bounded - standard overlap check - return start2 < end1 and start1 < end2 + if end1 is None or end2 is None: # Defensive; mypy needs guards + return False + # At this point, start1/start2/type narrowing ensures they're not None either + _start1, _start2 = start1, start2 # type: ignore[misc] + return _start2 < end1 and _start1 < end2 # type: ignore[operator] # Helper to find overlapping sub-range - def _overlap_range(start1: date | None, end1: date | None, - start2: date | None, end2: date | None) -> tuple[date | None, date | None]: + def _overlap_range( + start1: date | None, end1: date | None, start2: date | None, end2: date | None + ) -> tuple[date | None, date | None]: """Return the overlapping sub-range of two ranges.""" - + # Handle completely unbounded cases first if start1 is None and end1 is None: - return (start2, end2) if any(x is not None for x in (start2, end2)) else (None, None) + return ( + (start2, end2) + if any(x is not None for x in (start2, end2)) + else (None, None) + ) if start2 is None and end2 is None: - return (start1, end1) if any(x is not None for x in (start1, end1)) else (None, None) - - # Both ranges are bounded or partially unbounded + return ( + (start1, end1) + if any(x is not None for x in (start1, end1)) + else (None, None) + ) + + # Both ranges are bounded or partially unbounded (after handling fully unbounded) o_start = max((s for s in (start1, start2) if s is not None), default=None) o_end = min((e for e in (end1, end2) if e is not None), default=None) - - # Check for actual overlap - if o_start is None or o_start > o_end: + + # If either was filtered out, there's no overlap + if o_start is None or o_end is None: return (None, None) - + + if o_start > o_end: + return (None, None) + return (o_start, o_end) existing_budgets = ( - session.query(CategoryBudgetDb) - .filter_by(category_id=category_db.id) - .all() + session.query(CategoryBudgetDb).filter_by(category_id=category_db.id).all() ) def _remove_and_split_budget(budget: CategoryBudgetDb): @@ -125,7 +139,7 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): ) session.add(row) # Add the unchanged budget back return [row] # No overlap, keep the whole existing budget - + # Calculate overlap boundaries overlap_start, overlap_end = _overlap_range( new_start, new_end, budget.valid_from, budget.valid_to @@ -136,20 +150,22 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): row = _add_row(new_amount, new_start, new_end) session.add(row) return [row] - + # Split the budget into non-overlapping parts and add the new budget parts = [] - + # Part 1: Before the overlap (if any) if budget.valid_from is not None and budget.valid_from < overlap_start: - row = _add_row(budget.amount_eur, budget.valid_from, overlap_start - ONE_DAY) + row = _add_row( + budget.amount_eur, budget.valid_from, overlap_start - ONE_DAY + ) parts.append(row) - - # Part 2: After the overlap (if any) + + # Part 2: After the overlap (if any) if budget.valid_to is not None and overlap_end < budget.valid_to: row = _add_row(budget.amount_eur, overlap_end + ONE_DAY, budget.valid_to) parts.append(row) - + # Add the new budget row = _add_row(new_amount, new_start, new_end) parts.append(row) @@ -157,23 +173,24 @@ def set_category_budget(session: Session, category_budget: CategoryBudget): # Add all parts to session for row in parts: session.add(row) - + return parts # Track if new budget was already added (during overlap handling) new_budget_added = False - + # Process each existing budget for existing_budget in existing_budgets: - if not _ranges_overlap(new_start, new_end, - existing_budget.valid_from, existing_budget.valid_to): + if not _ranges_overlap( + new_start, new_end, existing_budget.valid_from, existing_budget.valid_to + ): continue # No overlap, keep existing budget as-is - + # Remove and split the overlapping budget session.delete(existing_budget) - + # Add back non-overlapping parts plus the new budget - split_parts = _remove_and_split_budget(existing_budget) + _remove_and_split_budget(existing_budget) new_budget_added = True # Mark that we already added the new budget # Always add the new budget unless it was already added during overlap handling diff --git a/src/models/category.py b/src/models/category.py index 12b457d..92b1ade 100644 --- a/src/models/category.py +++ b/src/models/category.py @@ -83,12 +83,10 @@ class CategoryBudget(BaseModel): category: Category amount_eur: Decimal = Field(gt=0, decimal_places=2) valid_from: date | None = Field( - default=None, - description="Start of validity. None means unbounded" + default=None, description="Start of validity. None means unbounded" ) valid_to: date | None = Field( - default=None, - description="End of validity. None means unbounded" + default=None, description="End of validity. None means unbounded" ) @model_validator(mode="after") diff --git a/tests/test_category_edge_cases.py b/tests/test_category_edge_cases.py index ccd8c6f..2aab705 100644 --- a/tests/test_category_edge_cases.py +++ b/tests/test_category_edge_cases.py @@ -1,6 +1,7 @@ -import pytest from datetime import date +import pytest + from src.models.category import Category, CategoryBudget @@ -20,7 +21,7 @@ class TestCategoryComputedFields: """Test hash_id for multi-level category hierarchy.""" c1 = Category.from_str("level1:level2:level3") assert hasattr(c1, "hash_id") - + c2 = Category.from_str("level1:level2:level3") assert c1.hash_id == c2.hash_id @@ -35,10 +36,10 @@ class TestCategoryComputedFields: """Test that same name but different parent gives different hash.""" root = Category(name="Budget") cat1 = Category(name="Budget", parent=root) - + # Create another with same name but no parent orphan = Category.from_str("Budget") - + assert cat1.hash_id != orphan.hash_id def test_to_str_single_level(self): @@ -84,7 +85,7 @@ class TestCategoryBudgetValidation: def test_valid_budget_with_dates(self): """Budget with valid date range is accepted.""" - from datetime import date + cat = Category.from_str("test") budget = CategoryBudget( category=cat, @@ -97,9 +98,9 @@ class TestCategoryBudgetValidation: def test_invalid_date_range_raises(self): """Budget with valid_from > valid_to raises ValueError.""" - from datetime import date + cat = Category.from_str("test") - + with pytest.raises(ValueError) as exc_info: CategoryBudget( category=cat, @@ -107,14 +108,14 @@ class TestCategoryBudgetValidation: valid_from=date(2026, 10, 31), valid_to=date(2026, 10, 1), ) - + assert "valid_from" in str(exc_info.value) assert "valid_to" in str(exc_info.value) assert "nor in order" in str(exc_info.value) def test_valid_budget_exact_boundary(self): """Budget with exact same from and to is valid (single day).""" - from datetime import date + cat = Category.from_str("test") budget = CategoryBudget( category=cat, @@ -127,7 +128,7 @@ class TestCategoryBudgetValidation: def test_validation_preserves_original_values(self): """Validation doesn't change the model's original values.""" - from datetime import date + cat = Category.from_str("test") budget = CategoryBudget( category=cat, @@ -135,10 +136,10 @@ class TestCategoryBudgetValidation: valid_from=date(2026, 9, 1), valid_to=date(2026, 9, 30), ) - - # Create an invalid one and verify validation catches it + + # Create an invalid one (valid_from > valid_to) and verify validation catches it try: - bad_budget = CategoryBudget( + CategoryBudget( category=cat, amount_eur=200.00, valid_from=date(2026, 12, 31), @@ -146,6 +147,6 @@ class TestCategoryBudgetValidation: ) except ValueError: pass # Expected - + # Valid one should still have original values assert budget.amount_eur == 150.75 diff --git a/tests/test_db_utils_update_path.py b/tests/test_db_utils_update_path.py index 8033734..c48cfb8 100644 --- a/tests/test_db_utils_update_path.py +++ b/tests/test_db_utils_update_path.py @@ -5,9 +5,10 @@ The upsert function has two paths: 2. Row doesn't exist -> insert new row These tests cover the update path (lines 25-26). + +NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes. """ -from sqlalchemy import select from sqlalchemy.orm import Session from src.db_utils import upsert @@ -18,117 +19,139 @@ class TestUpsertUpdatePath: def test_upsert_updates_existing_row(self, db_session: Session): """Test that upsert updates an existing row when do_update=True.""" - from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, String - + from sqlalchemy.orm import declarative_base + Base = declarative_base() - + class TestModel(Base): __tablename__ = "test_model" id: int = Column(Integer, primary_key=True, autoincrement=True) name: str = Column(String(100), nullable=False) value: str = Column(String(50), nullable=False) - + Base.metadata.create_all(db_session.get_bind()) - + # Create and insert a row initial = TestModel(name="initial", value="old_value") db_session.add(initial) db_session.commit() - + # Verify it was inserted - result = db_session.query(TestModel).filter_by(name="initial").one() # type: ignore + result = db_session.query(TestModel).filter_by(name="initial").one() assert result.value == "old_value" def test_upsert_skips_update_when_do_update_false(self, db_session: Session): """Test that upsert doesn't modify existing row when do_update=False.""" - from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, String - + from sqlalchemy.orm import declarative_base + Base = declarative_base() - + class TestModel(Base): __tablename__ = "test_model_2" id: int = Column(Integer, primary_key=True, autoincrement=True) name: str = Column(String(100), nullable=False) value: str = Column(String(50), nullable=False) - + Base.metadata.create_all(db_session.get_bind()) - + # Create and insert a row initial = TestModel(name="skip_update", value="original") db_session.add(initial) db_session.commit() - + # Upsert with do_update=False should not modify existing row same_row = TestModel(name="skip_update", value="should_not_change") - - result = upsert(db_session, TestModel, {"name": "skip_update"}, same_row.__dict__, - do_update=False, do_commit=True) - + + result = upsert( + db_session, + TestModel, + {"name": "skip_update"}, + same_row.__dict__, + do_update=False, + do_commit=True, + ) + # The row should still have original values assert result.value == "original" def test_upsert_updates_and_returns_existing(self, db_session: Session): """Test that upsert returns the existing (now updated) row when do_update=True.""" - from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, String - + from sqlalchemy.orm import declarative_base + Base = declarative_base() - + class TestModel(Base): __tablename__ = "test_model_3" id: int = Column(Integer, primary_key=True, autoincrement=True) name: str = Column(String(100), nullable=False) value: str = Column(String(50), nullable=False) - + Base.metadata.create_all(db_session.get_bind()) - + # Create and insert a row initial = TestModel(name="update_test", value="old") db_session.add(initial) db_session.commit() - + # Upsert with do_update=True should update and return the same object new_values = TestModel(name="update_test", value="new_value") - - result = upsert(db_session, TestModel, {"name": "update_test"}, new_values.__dict__, - do_update=True, do_commit=True) - + + result = upsert( + db_session, + TestModel, + {"name": "update_test"}, + new_values.__dict__, + do_update=True, + do_commit=True, + ) + # Result should be the same row (not a new one) assert result is not None # Should have updated values assert result.value == "new_value" - def test_upsert_returns_updated_row_by_value_when_not_same_object(self, db_session: Session): + def test_upsert_returns_updated_row_by_value_when_not_same_object( + self, db_session: Session + ): """Test update path when the returned row is same by identity (session cache).""" - from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, String - + from sqlalchemy.orm import declarative_base + Base = declarative_base() - + class TestModel(Base): __tablename__ = "test_model_4" id: int = Column(Integer, primary_key=True, autoincrement=True) name: str = Column(String(100), nullable=False) value: str = Column(String(50), nullable=False) - + Base.metadata.create_all(db_session.get_bind()) - + # Create and insert a row initial = TestModel(name="identity_test", value="initial") db_session.add(initial) db_session.commit() - + # Get the row from DB again (fresh load) - before_update = db_session.query(TestModel).filter_by(name="identity_test").one() # type: ignore - + before_update = ( + db_session.query(TestModel).filter_by(name="identity_test").one() + ) + # Now upsert should return same object due to session cache + identity map new_values = TestModel(name="identity_test", value="updated") - - result = upsert(db_session, TestModel, {"name": "identity_test"}, new_values.__dict__, - do_update=True, do_commit=False) # don't commit yet - + + result = upsert( + db_session, + TestModel, + {"name": "identity_test"}, + new_values.__dict__, + do_update=True, + do_commit=False, + ) # don't commit yet + # Should be same identity due to session cache assert result is before_update assert result.value == "updated" @@ -139,31 +162,39 @@ class TestUpsertUpdateEdgeCases: def test_upsert_partial_update(self, db_session: Session): """Test that only specified fields are updated.""" - from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, String - + from sqlalchemy.orm import declarative_base + Base = declarative_base() - + class TestModel(Base): __tablename__ = "test_model_5" id: int = Column(Integer, primary_key=True, autoincrement=True) name: str = Column(String(100), nullable=False) value: str = Column(String(50), nullable=False) extra: str = Column(String(50), nullable=False) - + Base.metadata.create_all(db_session.get_bind()) - + # Create and insert a row with all fields - initial = TestModel(name="partial", value="original_value", extra="extra_original") + initial = TestModel( + name="partial", value="original_value", extra="extra_original" + ) db_session.add(initial) db_session.commit() - + # Create new values with only some fields set (note: we skip 'extra' field) partial_update = TestModel(name="partial", value="new_value", extra=None) - - result = upsert(db_session, TestModel, {"name": "partial"}, partial_update.__dict__, - do_update=True, do_commit=True) - + + result = upsert( + db_session, + TestModel, + {"name": "partial"}, + partial_update.__dict__, + do_update=True, + do_commit=True, + ) + # value should be updated since it was in the dict assert result.value == "new_value" # extra field handling depends on what's in the __dict__ @@ -174,28 +205,34 @@ class TestUpsertUpdateEmptyDict: def test_upsert_with_empty_update_dict(self, db_session: Session): """Test upsert with empty parameters_new dict - should still work if existing found.""" - from sqlalchemy.orm import declarative_base from sqlalchemy import Column, Integer, String - + from sqlalchemy.orm import declarative_base + Base = declarative_base() - + class TestModel(Base): __tablename__ = "test_model_6" id: int = Column(Integer, primary_key=True, autoincrement=True) name: str = Column(String(100), nullable=False) value: str = Column(String(50), nullable=False) - + Base.metadata.create_all(db_session.get_bind()) - + # Create and insert a row initial = TestModel(name="empty_dict", value="test") db_session.add(initial) db_session.commit() - + # Try to upsert with empty new parameters (edge case) - result = upsert(db_session, TestModel, {"name": "empty_dict"}, {}, - do_update=True, do_commit=False) - + result = upsert( + db_session, + TestModel, + {"name": "empty_dict"}, + {}, + do_update=True, + do_commit=False, + ) + # Should return the existing row unchanged assert result is not None assert result.name == "empty_dict" diff --git a/tests/test_domain_utils_budget_overlap.py b/tests/test_domain_utils_budget_overlap.py index fc968d8..727364f 100644 --- a/tests/test_domain_utils_budget_overlap.py +++ b/tests/test_domain_utils_budget_overlap.py @@ -5,10 +5,12 @@ from datetime import date import pytest from sqlalchemy.orm import Session -from src.domain_utils import set_category_budget, find_category_db +from src.domain_utils import find_category_db, set_category_budget from src.models import Category, CategoryBudget, CategoryBudgetDb from src.models.category import upsert_category +# NOTE: MYPY errors suppressed for test fixtures - see AGENTS.md for type checking notes. + class TestSetCategoryBudgetNoOverlap: """Test budgets that don't overlap with existing ones.""" @@ -37,7 +39,9 @@ class TestSetCategoryBudgetNoOverlap: ) set_category_budget(db_session, budget2) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 def test_completely_before_no_overlap(self, db_session: Session): @@ -64,7 +68,9 @@ class TestSetCategoryBudgetNoOverlap: ) set_category_budget(db_session, new_budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 @@ -95,7 +101,9 @@ class TestSetCategoryBudgetFullOverlap: ) set_category_budget(db_session, new_budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) # Should have only 1 budget with the new amount (replaced old one) assert len(budgets) == 1 assert budgets[0].amount_eur == 200.00 @@ -131,7 +139,9 @@ class TestSetCategoryBudgetPartialOverlapStart: # Should have 2 budgets: # 1. Oct 1 - Nov 15 (new budget) # 2. Dec 1 - Dec 30 (remainder from old budget, after overlap end + 1 day) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 @@ -165,7 +175,9 @@ class TestSetCategoryBudgetPartialOverlapEnd: # Should have 2 budgets: # 1. Oct 1 - Oct 14 (remainder from old budget, before overlap start - 1 day) # 2. Oct 15 - Nov 30 (new budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 @@ -200,7 +212,9 @@ class TestSetCategoryBudgetMiddleOverlap: # 1. Oct 1 - Oct 14 (before new range, original amount) # 2. Oct 15 - Nov 30 (new budget, new amount) # 3. Dec 1 - Dec 31 (after new range, original amount) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 3 @@ -221,7 +235,9 @@ class TestSetCategoryBudgetUnbounded: ) set_category_budget(db_session, budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 1 assert budgets[0].valid_from is None assert budgets[0].valid_to == date(2026, 12, 31) @@ -240,7 +256,9 @@ class TestSetCategoryBudgetUnbounded: ) set_category_budget(db_session, budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 1 assert budgets[0].valid_from == date(2026, 10, 1) assert budgets[0].valid_to is None @@ -255,11 +273,13 @@ class TestSetCategoryBudgetUnbounded: category=cat, amount_eur=100.00, valid_from=None, # Unbounded start - valid_to=None, # Unbounded end + valid_to=None, # Unbounded end ) set_category_budget(db_session, budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 1 assert budgets[0].valid_from is None assert budgets[0].valid_to is None @@ -292,7 +312,9 @@ class TestSetCategoryBudgetWithExistingUnbounded: ) set_category_budget(db_session, bounded_budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 def test_add_bounded_before_unbounded(self, db_session: Session): @@ -319,7 +341,9 @@ class TestSetCategoryBudgetWithExistingUnbounded: ) set_category_budget(db_session, bounded_budget) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 @@ -350,7 +374,9 @@ class TestSetCategoryBudgetMultipleWithSameAmount: ) set_category_budget(db_session, budget2) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 1 assert budgets[0].amount_eur == 200.00 @@ -382,7 +408,9 @@ class TestSetCategoryBudgetDateEdgeCases: ) set_category_budget(db_session, budget2) - budgets = list(db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id)) + budgets = list( + db_session.query(CategoryBudgetDb).filter_by(category_id=cat_db.id) + ) assert len(budgets) == 2 @@ -404,11 +432,11 @@ class TestFindCategoryDb: grandfather = Category(name="grandfather") upsert_category(db_session, grandfather, do_commit=False) db_session.commit() - + father = Category(name="father", parent=grandfather) upsert_category(db_session, father, do_commit=False) db_session.commit() - + child = Category(name="child", parent=father) upsert_category(db_session, child, do_commit=False) db_session.commit() @@ -457,9 +485,11 @@ class TestSetCategoryBudgetErrorHandling: set_category_budget(db_session, budget) # The error should mention that the category was not found (including its parent) - assert "ChildOfMissing" in str(exc_info.value).lower() or \ - "not found" in str(exc_info.value).lower() or \ - "no matching" in str(exc_info.value).lower() + assert ( + "ChildOfMissing" in str(exc_info.value).lower() + or "not found" in str(exc_info.value).lower() + or "no matching" in str(exc_info.value).lower() + ) class TestSetCategoryBudgetValidation: @@ -490,7 +520,7 @@ class TestSetCategoryBudgetValidation: def test_invalid_date_range_raises(self): """Budget with valid_from > valid_to should raise validation error.""" cat = Category.from_str("test") - + with pytest.raises(ValueError) as exc_info: CategoryBudget( category=cat, @@ -498,13 +528,13 @@ class TestSetCategoryBudgetValidation: valid_from=date(2026, 11, 30), valid_to=date(2026, 10, 1), ) - + assert "valid_from" in str(exc_info.value) or "valid_to" in str(exc_info.value) def test_amount_must_be_positive(self): """Amount must be greater than zero.""" cat = Category.from_str("test") - + with pytest.raises(ValueError) as exc_info: CategoryBudget( category=cat, @@ -512,5 +542,5 @@ class TestSetCategoryBudgetValidation: valid_from=date(2026, 10, 1), valid_to=date(2026, 11, 30), ) - + assert "greater than" in str(exc_info.value).lower() diff --git a/tests/test_models_common.py b/tests/test_models_common.py index 91de7a9..1105fef 100644 --- a/tests/test_models_common.py +++ b/tests/test_models_common.py @@ -2,12 +2,7 @@ from datetime import date -import pytest - -from src.domain_utils import set_category_budget, find_category_db -from src.models.common import Sign, date2str, ExportBaseModel -from src.models.category import Category, CategoryBudget, CategoryBudgetDb -from src.models.b4w import B4wExport +from src.models.common import ExportBaseModel, Sign, date2str class TestSignEnum: @@ -22,7 +17,7 @@ class TestSignEnum: """Test Sign.__repr__ method (line 24).""" repr_debit = repr(Sign.DEBIT) assert repr_debit == "Sign.DEBIT" - + repr_credit = repr(Sign.CREDIT) assert repr_credit == "Sign.CREDIT" @@ -68,64 +63,70 @@ class TestExportBaseModel: def test_parameters_to_dict_empty(self): """parameters_to_dict returns empty dict (line 70).""" + class TestModel(ExportBaseModel): pass - + model = TestModel() result = model.parameters_to_dict() assert result == {} def test_parameters_to_str_no_params(self): """parameters_to_str with no parameters.""" + class TestModel(ExportBaseModel): pass - + model = TestModel() result = model.parameters_to_str() assert result == "" def test_repr_empty_params(self): """__repr__ with empty parameters (line 79).""" + class TestModel(ExportBaseModel): pass - + model = TestModel() - + # __repr__ should return string representation with empty params repr_result = repr(model) assert "TestModel" in repr_result def test_str_empty_params(self): """__str__ with empty parameters.""" + class TestModel(ExportBaseModel): pass - + model = TestModel() - - # __str__ should return string representation + + # __str__ should return string representation str_result = str(model) assert "TestModel" in str_result def test_repr_with_params(self): """Test repr with actual parameters.""" + class ConfigurableModel(ExportBaseModel): def parameters_to_dict(self) -> dict[str, str]: return {"key": "value"} - + model = ConfigurableModel() - + # Test that repr works correctly with params repr_result = repr(model) assert "key" in repr_result or "value" in repr_result def test_str_with_params(self): """Test str with actual parameters.""" + class ConfigurableModel(ExportBaseModel): def parameters_to_dict(self) -> dict[str, str]: return {"name": "test"} - + model = ConfigurableModel() - + # Test that str works correctly with params str_result = str(model) assert "name" in str_result or "test" in str_result @@ -136,8 +137,9 @@ class TestMyNameMethod: def test_my_name_returns_classname(self): """_my_name should return the class name.""" + class SubModel(ExportBaseModel): pass - + result = SubModel._my_name() assert result == "SubModel" diff --git a/tests/test_models_init.py b/tests/test_models_init.py index 0733071..65753ac 100644 --- a/tests/test_models_init.py +++ b/tests/test_models_init.py @@ -1,6 +1,7 @@ """Tests for src.models.__init__.py lazy loading and __dir__.""" import pytest + from src import models @@ -10,50 +11,58 @@ class TestLazyLoading: def test_load_b4wexport(self): """Test loading B4wExport model.""" from src.models.b4w import B4wExport as DirectB4wExport + b4w = models.B4wExport assert b4w is DirectB4wExport def test_load_category(self): """Test loading Category model.""" from src.models.category import Category as DirectCategory + cat = models.Category assert cat is DirectCategory def test_load_categorybudgetdb(self): """Test loading CategoryBudgetDb model.""" from src.models.category import CategoryBudgetDb as DirectCategoryBudgetDb + cbdb = models.CategoryBudgetDb assert cbdb is DirectCategoryBudgetDb def test_load_comdirectgiro(self): """Test loading ComdirectGiro model.""" from src.models.comdirect import ComdirectGiro as DirectComdirectGiro + giro = models.ComdirectGiro assert giro is DirectComdirectGiro def test_load_transaction(self): """Test loading Transaction model.""" from src.models.transactions import Transaction as DirectTransaction + tx = models.Transaction assert tx is DirectTransaction def test_load_account(self): """Test loading Account model.""" from src.models.settings import Account as DirectAccount + acc = models.Account assert acc is DirectAccount def test_load_source(self): """Test loading Source model.""" from src.models.settings import Source as DirectSource + src = models.Source assert src is DirectSource def test_nonexistent_attribute_raises(self): """Test that non-existent attributes raise AttributeError.""" with pytest.raises(AttributeError) as exc_info: - models.NonExistentModel - + # This is intentional: testing that accessing a non-existent attribute raises + _ = models.NonExistentModel # type: ignore[name-defined] + expected_msg = "module 'src.models' has no attribute 'NonExistentModel'" assert expected_msg in str(exc_info.value) @@ -71,7 +80,7 @@ class TestDirMethod: def test_dir_includes_all_exports(self): """Test that __dir__() includes all exported models.""" result = models.__dir__() - + expected_exports = { "B4wExport", "Category", @@ -84,7 +93,7 @@ class TestDirMethod: "Transaction", "TransactionDb", } - + # All exports should be in dir() for export_name in expected_exports: assert export_name in result