From 179be8bb44954257e13bbcc6875cf27c003c8494 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 23 Jul 2026 02:41:55 -0700 Subject: [PATCH 1/3] fix(gitlinks): convert phantom 160000 gitlinks to tracked .gitkeep directories The rationalization absorb commit f5f4e75 registered 8 nested packages/* paths as mode 160000 (gitlink) entries without matching .gitmodules entries, and pointed them at commits that are no longer reachable in the consolidated object database. This caused every downstream consumer (notably thegent's `uv run pytest`) to fail with: fatal: no url found for submodule path 'packages/auth-kit/go' in .gitmodules Replace each phantom gitlink with a tracked .gitkeep directory placeholder so the path layout is preserved without dangling gitlink entries. Add a regression test in tests/test_gitlinks.py that fails whenever any (160000) gitlink appears in the index without a matching .gitmodules entry whose target commit is reachable. Refs: thegent AUDIT-N+39 dependency unblock. --- packages/auth-kit/go | 1 - packages/auth-kit/go/.gitkeep | 0 packages/auth-kit/python/pheno-auth | 1 - packages/auth-kit/python/pheno-auth/.gitkeep | 0 packages/auth-kit/python/pheno-security | 1 - .../auth-kit/python/pheno-security/.gitkeep | 0 .../observability-kit/python/pheno-logging | 1 - .../python/pheno-logging/.gitkeep | 0 .../python/pheno-observability | 1 - .../python/pheno-observability/.gitkeep | 0 packages/observability-kit/rust/helix-logging | 1 - .../rust/helix-logging/.gitkeep | 0 packages/observability-kit/rust/helix-tracing | 1 - .../rust/helix-tracing/.gitkeep | 0 packages/testing-kit/python/pheno-quality | 1 - .../testing-kit/python/pheno-quality/.gitkeep | 0 scripts/convert_gitlinks_to_gitkeep.py | 55 +++++++ .../conftest.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 412 bytes ...test_gitlinks.cpython-314-pytest-9.0.3.pyc | Bin 0 -> 9817 bytes tests/conftest.py | 1 + tests/test_gitlinks.py | 145 ++++++++++++++++++ 21 files changed, 201 insertions(+), 8 deletions(-) delete mode 160000 packages/auth-kit/go create mode 100644 packages/auth-kit/go/.gitkeep delete mode 160000 packages/auth-kit/python/pheno-auth create mode 100644 packages/auth-kit/python/pheno-auth/.gitkeep delete mode 160000 packages/auth-kit/python/pheno-security create mode 100644 packages/auth-kit/python/pheno-security/.gitkeep delete mode 160000 packages/observability-kit/python/pheno-logging create mode 100644 packages/observability-kit/python/pheno-logging/.gitkeep delete mode 160000 packages/observability-kit/python/pheno-observability create mode 100644 packages/observability-kit/python/pheno-observability/.gitkeep delete mode 160000 packages/observability-kit/rust/helix-logging create mode 100644 packages/observability-kit/rust/helix-logging/.gitkeep delete mode 160000 packages/observability-kit/rust/helix-tracing create mode 100644 packages/observability-kit/rust/helix-tracing/.gitkeep delete mode 160000 packages/testing-kit/python/pheno-quality create mode 100644 packages/testing-kit/python/pheno-quality/.gitkeep create mode 100644 scripts/convert_gitlinks_to_gitkeep.py create mode 100644 tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/__pycache__/test_gitlinks.cpython-314-pytest-9.0.3.pyc create mode 100644 tests/conftest.py create mode 100644 tests/test_gitlinks.py diff --git a/packages/auth-kit/go b/packages/auth-kit/go deleted file mode 160000 index a2ccd52..0000000 --- a/packages/auth-kit/go +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a2ccd52b338b836f663707cab3389ddb69ddbc3b diff --git a/packages/auth-kit/go/.gitkeep b/packages/auth-kit/go/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/auth-kit/python/pheno-auth b/packages/auth-kit/python/pheno-auth deleted file mode 160000 index cf45dfa..0000000 --- a/packages/auth-kit/python/pheno-auth +++ /dev/null @@ -1 +0,0 @@ -Subproject commit cf45dfa7d3f831ef2cd74d2cc149da8081381fe9 diff --git a/packages/auth-kit/python/pheno-auth/.gitkeep b/packages/auth-kit/python/pheno-auth/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/auth-kit/python/pheno-security b/packages/auth-kit/python/pheno-security deleted file mode 160000 index b4bb26e..0000000 --- a/packages/auth-kit/python/pheno-security +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b4bb26ef5b225c9707507fea613923a2363c7c0e diff --git a/packages/auth-kit/python/pheno-security/.gitkeep b/packages/auth-kit/python/pheno-security/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/observability-kit/python/pheno-logging b/packages/observability-kit/python/pheno-logging deleted file mode 160000 index 4d3d8c1..0000000 --- a/packages/observability-kit/python/pheno-logging +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4d3d8c1d929b138891615ae86d2dcb33c98e0d62 diff --git a/packages/observability-kit/python/pheno-logging/.gitkeep b/packages/observability-kit/python/pheno-logging/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/observability-kit/python/pheno-observability b/packages/observability-kit/python/pheno-observability deleted file mode 160000 index 553fa96..0000000 --- a/packages/observability-kit/python/pheno-observability +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 553fa967beaeefaff110f7916607b5547ec72673 diff --git a/packages/observability-kit/python/pheno-observability/.gitkeep b/packages/observability-kit/python/pheno-observability/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/observability-kit/rust/helix-logging b/packages/observability-kit/rust/helix-logging deleted file mode 160000 index c52b01c..0000000 --- a/packages/observability-kit/rust/helix-logging +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c52b01c097965aeaad5747fef47f25137e293e75 diff --git a/packages/observability-kit/rust/helix-logging/.gitkeep b/packages/observability-kit/rust/helix-logging/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/observability-kit/rust/helix-tracing b/packages/observability-kit/rust/helix-tracing deleted file mode 160000 index a3fd4b2..0000000 --- a/packages/observability-kit/rust/helix-tracing +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a3fd4b2d7197b306ea5fbe9eebb4de0b950c2245 diff --git a/packages/observability-kit/rust/helix-tracing/.gitkeep b/packages/observability-kit/rust/helix-tracing/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/packages/testing-kit/python/pheno-quality b/packages/testing-kit/python/pheno-quality deleted file mode 160000 index 12f764f..0000000 --- a/packages/testing-kit/python/pheno-quality +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 12f764f5b9f216461b1e95d12ea6aa12519aca66 diff --git a/packages/testing-kit/python/pheno-quality/.gitkeep b/packages/testing-kit/python/pheno-quality/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/scripts/convert_gitlinks_to_gitkeep.py b/scripts/convert_gitlinks_to_gitkeep.py new file mode 100644 index 0000000..7d5949f --- /dev/null +++ b/scripts/convert_gitlinks_to_gitkeep.py @@ -0,0 +1,55 @@ +"""Convert malformed nested gitlinks to regular directory placeholders. + +Background +---------- + +The rationalization absorb (commit ``f5f4e75``) registered 8 nested +``mode 160000`` entries for sub-packages that no longer have reachable +commits in the consolidated object database. Replacing them with a +tracked ``.gitkeep`` file preserves the path layout (so external +downstreams that depend on the directory's existence continue to work) +without leaving the index poisoned by dangling gitlinks. + +Each conversion is one ``git rm --cached`` + ``git add`` of a single +``.gitkeep`` file. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +GITLINKS = [ + "packages/auth-kit/go", + "packages/auth-kit/python/pheno-auth", + "packages/auth-kit/python/pheno-security", + "packages/observability-kit/python/pheno-logging", + "packages/observability-kit/python/pheno-observability", + "packages/observability-kit/rust/helix-logging", + "packages/observability-kit/rust/helix-tracing", + "packages/testing-kit/python/pheno-quality", +] + + +def _run(*args: str) -> None: + subprocess.run(args, cwd=REPO_ROOT, check=True) + + +def main() -> int: + for rel in GITLINKS: + target = REPO_ROOT / rel + target.mkdir(parents=True, exist_ok=True) + keep = target / ".gitkeep" + keep.touch(exist_ok=True) + + _run("git", "rm", "--cached", "--ignore-unmatch", rel) + _run("git", "add", str(keep.relative_to(REPO_ROOT))) + print(f"converted {rel} -> {keep.relative_to(REPO_ROOT)}") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.0.3.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3e483fcd3b6502ea8d1fe7a34908ba61fa3da807 GIT binary patch literal 412 zcmX|7%}T>S5ZnCnOC8LMIuZ($k=Zh6lm~l*yo|)@cNwRADdy3{Xx{8lymOYNN+~ z(>CKcg(Qw`>!nn4Qrg(gO;N>rbQW(~MZy?~>O_ zEb%9&G&~0|nJ5G*K7dq?5N0xQBabqvfvQ4slm!^R0L%%cG`!elLf13{9hx-f6ZIB~ zlCm&Y6iyY*mCpHQ`DFVmQBm2})hndsTwY_>4dJQ=%wQ;)-Ns!u*pc(vlfbj;kNj)gKMhP&}ptE`cQh3k2Z1 z3rS4!z$R&ivfW5dlQG>)8g}|3P5PirXByAs#hJeJp%0`fiO5DBH2i%Ks`qaM@KMzH5)uPKYCZ`5Bv-)NN?D7^`qLGea06+FE!hepF@xQ(3)?`G$! z2Z52w*iQTDt_Mqvv0d(78{VwXtUFg1m3u;!(Pn+r*UX~+W|pZl8=_T!0|IW01_3Jq zZi{# zT%FZ2c+BcaHH$txX3}ORoxNbFX$(uH4Rtn`&2c@S>r<3J(-IeEc&?C5D!t{RDB~$z z<(dh^S|}=XpCnu+erU&%{L+Z4ynZv3!W#ssj`eMOMok*MgM>SKU=cz=V z9?F?1L+2N@sdOf7&QVWPHvsF|gift-keJTS5IC07vpE1nrkNishM1MMj+7=DQzmF+x*or zENkR4>7+)?&rQ9lCrlL$ZAvrrJ{7E0X0>d38auZcn>L0OMMas$pv*9#HVRWV9XQQ= z9t^_C;*4ko7meb$X=h^gGkKh)HlrK;8qT8k0$4hedqz=S%+6tJg^QDuy;B$gnv%Io zS;OSIHVb(GM-o~lV_*z+?^>D2D-a(prP3K)fuQ9w7m1Xlp4YQUoSTJOWLxpzb!70LT6R&^`TIMripxltG zke1EnY#A_4qb-l&D6Np|fPX{gsbr#<3>Z2uJx~G6vlu(?IrweHlIUw?uSh8=Dn&g} zZ`9N8fq{@e^E6FXVDYO3Q0J z-G2l+HO6x!yN0cq=3HLyC#zxfU&`?d5Fbcdo^+Pp^xEp$Ya}o9Pp2>UIwIIx(#C#q zqN0wMx{SX3oE478OI*<7aSqGRI`MC8M>WqLvw#%2Pt~;}_vo>BM6@2@?RpmxKt~nv zW%@Xyyop_3a%amFwgE* zH(x#f_W5F0ujK#d? zSr6$!sh};n+k8AGZ-&`f|DuP_J-_urR9FtRi+l}EECnMfPSEzPwk@{Jt!)`$`do&&Z=7$%a+Vad{I;9E;T6Je z1u{nOG@OfJRrQi(pV1>#oWzM!JjN;T;$Q`XPUI0kAd10IpMWht93vb;Bxrd_BLa?9 zv1A%%`C!IO!;)upQ?q0u959R;Y+*{Xq_{yxM^0B1TV-nFViRQp#fLFQNZ$7W%(I7n z7TR*P@9n-~V2?|08lX2J|Kl*Ls{V`Vd)3SrfCfDinIA3IHNIc-Ud^34bwMuHHNCIA zrz{RG)vwldEciaDZTe-nskmkP;=Uhzb+NGY%8xEDB#YZxf2@3<{4l&wy&zwEu^6ho zcJNMUM={)V^~{wsi{pzY?u5JU*RuNE_v%= zDV(08G*GWgq+YH$wDk=Ire2D9oH?5)S|=&m7+lB30`)98-n{(OHK63>HGDDO24#+6 z4d0X3c#fj3?q++}8|T~4bw}DK&RMl2{u9Qt zBlz#?>yE(5c(~3LVpe8kU5B&@1Z^lAA-G-oMUZB~zqu*i}L zwiH5b_ZZ|R_qf!lJ`No;bW$ffm^JuG3=z`u4FL13xJ#XX>27`F``f;^ZFzhD&89o` z&n@`wZfSo1_3ypD-1hv3L#ta}Tu?r#Z6RH$ZCR^rTd8eZl2&Wm7v%D=eK#@ec?=6} zxiVaA-Lux(v(nmg{gu_${kkR({O+kr*ZytMIKA1%>F3FA&=@( z9C<32%RE(GqL}1T7nKx5z4j`pi@G>2S{3pzR+N%QlOB8x&3P!odLfIlj;DJ$5!uAp ze%a;i@OJ}=%m&kl|@GqT zVirs>LK_jN>?s8*p?FF&;`Tj>LsxLY2Y+4=+x#R{^UK<%yRf>4-#%On)?8B-k1lmA zuQR_CwuJrjM=5NnyZXYF7Z$aZP}{QHR$-!Z#$PyOfrF}qEb{4GR)6ZLP7&4Q=O{wi ziR{FEQ7@VH(1-a|&@T3~o7oiB-(q=R_>d%sTd(_cgiT?Pmqgajb9&H1ff-N2WX{fQ_Ptyf5#~ z%ZQNt_^*mFnCi2oaaYd%f#mBsvH6bo)Bu|OEfo>&mIcR+RwG~$&3 z#wO_UAWAIgyeV@A9BySNV0mIyo8disWnJ|1y1frjuf+Ui99@DgWwZIvA>UY|vwr0n z^E;HdZw5|Z8>lH`hcU*kH^=VZ96KeHVpVlzIdJfP&RUAI*0b!bpF8l(PiWnVCWq!R zlHd(xH=7`DAWBSeRyi+GT$zNL5Z&uz)l<@Vd0aH$rY9PVDih_XcC6LGTY2s-@8@}R z&L9w6N8cvNxZPOap~~4;vU$TiwX%W(x#vM2lzyqlb<@j-Cpw&wF8*{neU&Hhgq`)B zCvM}Rd?*^~fR?ne0`wv8O|wYYI&@5AE=tjADM?VuRUmor#Eev*#Vn87Wl0gM(+PLg zOUP|d%E*;A5Sg#JVb${Vs8*yh^0T=t%2&*$kwMJP46EXH=WL7Y&3?L3KL?-CHK7CRQBOVXmTwEIWlf86nVL&lJAHn&JtlX6%yknu3VH&gvmSAK491b1yb ztN`2-QhBq+=}4mp|1IUEm&Z??Ir`ekW3O6%{W5N^h80BaEE%WUyNGBkU)sbJ%YRYJ z6m)~{#X`c9S@N5?bkvNvgM ziyNt2A(M zQ+BN=yKX7!htc^XcUzvlzJIl)e_7sI>>gR`KC;q%|x{v+c;L^Uu-G9>w z5bsx7j{iGi(38bT_xy=pZP|Gxh?`odcCC5uO7q^;=7C$Gfns>uTDWZ`+_p5hwCB3# z`u^)*y8hO(y6;wa072>9+U<+`5tiPnZ7YVG*TTD2!n>B_#joE9ci#`Ot-BDE?ASR! z`bkwI@-0_uuhbU912@Ni_WDm>|L_~DLnm*CPd@Z|g34n*3sgoNDAqJwObAXmXjvZp?x8h#6o3EVy*~Pr>JRI0bqz0WTiX4%J3b27 zFWmKQd351&x#Qz?t#UVz{{BHP+y0Uyg85&xRKL{g|KpNA98Q!yHE8-K1{vfyDQNOb z{`*wj>=chDL`=UG@=%GG?3~>oSrn8!@_TR9#~4KuZ?!ufVP;i$gi1t3=3qm?buk?C z?3bd|XX_9Vltx5-t~ku)A)Lvu|K;c@>W@}UP^>P>7zL3V%BKFULREOfa5RCRIex|2 zP07Di=50<_c^>>*RljGQfAhL_Md`N<1-uBX&yqScM1$)*x)Sql@SJib7IUr9uwel- zv9od%=U^w#`DKYWknfDtSR3c03QJ~5nZ|30er^L-%8dLBOWGhag7OCrf%`U<7F7aUfsy0UK}zcOU| zl_5&+`^nj)vw*hJt9=n7S9~U*=KhFrLlLQ3ZW0Ogar!DF?7sLaf%`h${5X;xj9;c| zj;aDxU#E(^;0>QmzsnHsbz9{Dw^bf=H=Z-d;S1wzo8~TIn!$&7nS*hgf$?NcH{hSm zxL`70!bG9R{|vxRk~LoY@>1Y>@2YZe{zx&@aF2O>{>6@!(9XvMJ*4V)@~7;f55U3! z(fMCa=e^B3A5-UluN$)1A?AsSkcoNj&h;3JfukQ8w*!Cv*b7XRnPr?}1=0V%xHf(< zqxTN@RK6+trPH`6QhhyXKc8UtuED~&Z;H0vqua+|>3#^?=ikKk{p%O9eKo@N)vmLB zRW1$Q+{RRFUmzO%6SaN8Kh*YBEN)a;XYE4&=hiMPtX=j0pW1p)2aolqe`;a)s5&dP z(MJ{X^zk1KBb|jKarqm#Dzcu!@ayzz3VfGVCMQql)B?|xzVRbwcKIF(!7)pi+CKg+ zOo!#S(OZ5SKmRUu277yxe6E+i7q&eG9lwSN{5`73+ws4q>ieipTYLY|pCIrb(B${2 z`hcn*qT1+6I?GW$&){l;-!aey#pPFMj~s~zCM@Ic0Kkm|SjcuGT{lmC^yO9M47rk? zW%_RWY7^uM-S;$0@}mJJb^Iq|-)7$m{)*NA8{78laNV_S@0?x>x37fT zSHqoa;qH}i_gZ*hB|I=6ELLw_+CtvYQXi?5bA9v` O2^WaR4dhP+E&l`X3?G63 literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f3d6abe --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +"""Conftest anchoring tests/ as the pytest rootdir for regression tests.""" diff --git a/tests/test_gitlinks.py b/tests/test_gitlinks.py new file mode 100644 index 0000000..33551c7 --- /dev/null +++ b/tests/test_gitlinks.py @@ -0,0 +1,145 @@ +"""Regression tests for malformed nested gitlinks in this monorepo. + +Background +---------- + +The rationalization commit ``f5f4e75 feat: absorb AuthKit/DataKit/McpKit/ +ObservabilityKit/ResilienceKit/TestingKit/PhenoKits into python SDK +monorepo`` introduced nested ``mode 160000`` (gitlink) entries for paths +that were never registered in ``.gitmodules`` and whose target commits +are no longer reachable in the consolidated object database. The +manifestation is: + + fatal: no submodule mapping found in .gitmodules for path + 'packages/auth-kit/go' + +Any ``uv``-backed downstream that calls ``git submodule status`` while +resolving dependencies fails immediately, which blocks consumers such +as ``thegent`` (``task test``). + +These tests pin the desired end-state: every ``mode 160000`` entry in +the index must either be (a) absent entirely, or (b) accompanied by a +matching ``.gitmodules`` entry whose target commit is present in the +local object database. +""" + +from __future__ import annotations + +import re +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +GITMODULES = REPO_ROOT / ".gitmodules" + + +def _run_git(*args: str) -> str: + result = subprocess.run( + ("git",) + args, + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + return result.stdout + result.stderr + + +def _index_gitlinks() -> list[tuple[str, str]]: + """Return (path, sha) tuples for every index entry in mode 160000.""" + + out = subprocess.run( + ("git", "ls-files", "--stage"), + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=True, + ).stdout + gitlinks: list[tuple[str, str]] = [] + for line in out.splitlines(): + parts = line.split("\t", 1) + if not parts: + continue + meta, path = parts[0], parts[1] + mode, sha, *_ = meta.split() + if mode == "160000": + gitlinks.append((path, sha)) + return gitlinks + + +def _parse_gitmodules(text: str) -> dict[str, str]: + mapping: dict[str, str] = {} + for block in re.split(r"\n(?=\[submodule )", text): + name_match = re.search(r'\[submodule "([^"]+)"\]', block) + if not name_match: + continue + name = name_match.group(1) + path_match = re.search(r"path\s*=\s*(.+)", block) + if path_match: + mapping[name] = path_match.group(1).strip() + return mapping + + +def _has_object(sha: str) -> bool: + if not sha: + return False + proc = subprocess.run( + ("git", "cat-file", "-e", sha), + cwd=REPO_ROOT, + capture_output=True, + text=True, + check=False, + ) + return proc.returncode == 0 + + +# --- Required: no malformed gitlinks remain ------------------------------ + + +def test_no_unregistered_gitlinks() -> None: + """Every gitlink in the index is registered in ``.gitmodules``.""" + + gitlinks = _index_gitlinks() + + raw = GITMODULES.read_text() if GITMODULES.exists() else "" + submodules_by_name = _parse_gitmodules(raw) + submodules_by_path = {v: k for k, v in submodules_by_name.items()} + + unregistered: list[str] = [] + unreachable: list[tuple[str, str]] = [] + for path, sha in gitlinks: + if path not in submodules_by_path.values(): + unregistered.append(f"{path} ({sha})") + continue + if not _has_object(sha): + unreachable.append((path, sha)) + + problems: list[str] = [] + if unregistered: + problems.append( + "gitlinks with no .gitmodules entry: " + ", ".join(unregistered) + ) + if unreachable: + problems.append( + "gitlinks whose target commit is missing: " + + ", ".join(f"{p} ({s})" for p, s in unreachable) + ) + + assert not problems, problems[0] if len(problems) == 1 else problems + + +def test_git_submodule_status_does_not_error() -> None: + """``git submodule status --recursive`` reports no fatal errors.""" + + out = _run_git("submodule", "status", "--recursive") + assert "fatal" not in out, ( + "git submodule status --recursive produced a fatal error:\n" + out + ) + + +def test_no_url_found_for_submodule_path() -> None: + """``git submodule update --init --recursive`` does not emit the + ``No url found for submodule path`` error.""" + + out = _run_git("submodule", "update", "--init", "--recursive", "--dry-run") + assert "No url found for submodule path" not in out, out + assert "fatal: no submodule mapping found in .gitmodules" not in out, out From a66db4b0c966c3eef98ea8134182390a28a13365 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 23 Jul 2026 18:08:08 -0700 Subject: [PATCH 2/3] fix(packaging): resolve upstream flat-layout auto-discovery error `uv pip install -e ` on the consolidated workspace failed with `error: Multiple top-level packages discovered in a flat-layout: ['okf', 'packages']`. This blocks external consumers from installing the workspace as a path source. Add explicit build-system configuration: `[tool.setuptools] py-modules = []` plus a no-op `[tool.setuptools.packages.find]` table that disables auto-discovery so the root project declares no installable packages. The root continues to act as a uv workspace aggregator under `[tool.uv] package = false`; per-kit installs still come from `[tool.uv.workspace] members`. Add regression test `tests/test_install_path.py` that runs `uv pip install` against the workspace path in a fresh temp venv and asserts: (a) the install exits 0, (b) the flat-layout error string never appears, (c) editable installs (`-e`) also succeed, (d) the pyproject.toml continues to declare the chosen fix. Pattern verified upstream in astral-sh/uv#12352. --- WORKLOG.md | 1 + pyproject.toml | 22 +++++ tests/__init__.py | 1 + tests/test_install_path.py | 179 +++++++++++++++++++++++++++++++++++++ 4 files changed, 203 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/test_install_path.py diff --git a/WORKLOG.md b/WORKLOG.md index b4cde30..b505902 100644 --- a/WORKLOG.md +++ b/WORKLOG.md @@ -7,3 +7,4 @@ | Date | Task ID | Layer | Action | Files | Notes | device | |---|---|---|---|---|---|---| | 2026-06-20 | T3B-2026-06-20 | governance | init | (all) | Initial SSOT bundle generation (T3B) | macbook | +| 2026-07-23 | AUDIT-LANE-FLAT-LAYOUT-001 | packaging | fix | pyproject.toml, tests/test_install_path.py, tests/__init__.py | Add `[tool.setuptools] py-modules = []` + `[tool.setuptools.packages.find]` no-op to suppress flat-layout auto-discovery; new regression test runs `uv pip install` against the workspace path and asserts success + no flat-layout error. DAG tick: +1. Second lane after PR #42 (fix/auth-kit-gitlink). | macbook | diff --git a/pyproject.toml b/pyproject.toml index dc24c8a..a228001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,6 +29,28 @@ readme = 'README.md' license = { text = 'MIT' } requires-python = '>=3.10' +# Root has no installable Python code — it's a uv workspace aggregator only. +# `tool.uv.package = false` already prevents IMPLICIT uv builds (`uv sync`, +# `uv run`), but EXPLICIT pip installs (`pip install `, +# `uv pip install `) ignore that flag and fall back to +# setuptools auto-discovery, which then refuses to build because the repo +# is a flat layout containing both `okf/` and `packages/` top-level +# directories. The minimal non-disruptive fix is to declare setuptools +# discovery rules that match nothing — no `__init__.py`-bearing top-level +# Python module is shipped from the root. Per-kit installs still come +# from `[tool.uv.workspace] members`. +# +# Verified working pattern, upstream discussion: astral-sh/uv#12352 +# (`[tool.setuptools] py-modules = []` suppresses the multi-package error +# because setuptools has no top-level modules to look for). +[tool.setuptools] +py-modules = [] + +[tool.setuptools.packages.find] +where = ["."] +include = [] +namespaces = false + [tool.uv] package = false diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/tests/test_install_path.py b/tests/test_install_path.py new file mode 100644 index 0000000..4917e18 --- /dev/null +++ b/tests/test_install_path.py @@ -0,0 +1,179 @@ +"""Regression tests for upstream packaging flat-layout defect. + +Background +---------- + +The consolidated workspace root has ``okf/`` and ``packages/`` as +top-level directories, so external consumers running +``uv pip install `` or ``pip install `` +on the upstream repo used to fail with:: + + error: Multiple top-level packages discovered in a flat-layout: + ['okf', 'packages'] + +because setuptools' default auto-discovery conflicted on those two +directories. The root project is a uv workspace aggregator only (no +installable Python module of its own), so ``[tool.uv] package = false`` +stops implicit uv builds, but explicit installs ignore that flag. + +These tests pin the desired end-state: the workspace must be installable +as a path source (no flat-layout error, exit 0) in a fresh venv. + +Traces to: AUDIT-LANE-FLAT-LAYOUT-001 (Phenotype-python-sdk packaging +audit, second lane after PR #42 ``fix/auth-kit-gitlink``). +""" + +from __future__ import annotations + +import shutil +import subprocess +import tempfile +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parent.parent +PYPROJECT = REPO_ROOT / "pyproject.toml" + +# A venv creation on macOS routinely takes ~1.5s and pip install ~2.5s, +# so give the subprocess a generous budget for CI runners. +_INSTALL_TIMEOUT_SECS = 120 + + +def _uv_available() -> bool: + return shutil.which("uv") is not None + + +def _create_temp_venv(python_version: str = "3.13") -> tuple[Path, Path]: + """Create an ephemeral Python venv at *tempdir/.venv* via uv. + + Returns ``(tempdir, venv_python)``. The caller is responsible for + cleaning up *tempdir* via :func:`_cleanup_temp_venv`. + """ + tempdir = Path(tempfile.mkdtemp(prefix="phenotype-sdk-install-")) + venv_dir = tempdir / ".venv" + proc = subprocess.run( + ( + "uv", + "venv", + "--python", + python_version, + str(venv_dir), + ), + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, ( + f"uv venv creation failed (rc={proc.returncode}):\n" + f"stdout={proc.stdout}\nstderr={proc.stderr}" + ) + return tempdir, venv_dir + + +def _cleanup_temp_venv(tempdir: Path) -> None: + """Best-effort teardown of a temp venv directory tree.""" + if not tempdir.exists(): + return + shutil.rmtree(tempdir, ignore_errors=True) + + +def _run_uv_pip_install( + venv_python: Path, + extra_args: tuple[str, ...] = (), +) -> subprocess.CompletedProcess[str]: + """Run ``uv pip install `` against the venv.""" + cmd = [ + "uv", + "pip", + "install", + "--python", + str(venv_python), + *extra_args, + str(REPO_ROOT), + ] + return subprocess.run( + cmd, + capture_output=True, + text=True, + check=False, + timeout=_INSTALL_TIMEOUT_SECS, + ) + + +# Skip the entire module if uv is missing — this is an upstream packaging +# regression, the test is meaningless without uv available. +pytestmark = pytest.mark.skipif( + not _uv_available(), + reason="uv CLI not available on PATH; install uv to run packaging tests", +) + + +@pytest.fixture +def temp_venv(): + """Yield ``(tempdir, venv_python)`` for one test, cleanup on exit.""" + tempdir, venv_dir = _create_temp_venv("3.13") + venv_python = venv_dir / "bin" / "python" + try: + yield tempdir, venv_python + finally: + _cleanup_temp_venv(tempdir) + + +def test_workspace_installs_as_path_source(temp_venv) -> None: + """``uv pip install `` completes with exit 0 in a fresh venv. + + Regression: previously failed with ``Multiple top-level packages + discovered in a flat-layout: ['okf', 'packages']``. + """ + _, venv_python = temp_venv + result = _run_uv_pip_install(venv_python) + + assert result.returncode == 0, ( + f"uv pip install failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + + +def test_workspace_install_does_not_emit_flat_layout_error(temp_venv) -> None: + """The flat-layout auto-discovery error string never appears, even + when the install fails for unrelated reasons.""" + _, venv_python = temp_venv + result = _run_uv_pip_install(venv_python) + + combined = result.stdout + "\n" + result.stderr + assert "Multiple top-level packages discovered in a flat-layout" not in combined, ( + "flat-layout auto-discovery regressed:\n" + combined + ) + assert "['okf', 'packages']" not in combined, ( + "flat-layout auto-discovery regressed (conflict list leaked):\n" + combined + ) + + +def test_workspace_editable_install_succeeds(temp_venv) -> None: + """``uv pip install -e `` (editable) also completes cleanly.""" + _, venv_python = temp_venv + result = _run_uv_pip_install(venv_python, extra_args=("-e",)) + + assert result.returncode == 0, ( + f"uv pip install -e failed:\nstdout={result.stdout}\nstderr={result.stderr}" + ) + assert "Multiple top-level packages" not in (result.stdout + result.stderr), ( + "editable install hit flat-layout error:\n" + result.stderr + ) + + +def test_pyproject_declares_empty_tool_setuptools() -> None: + """Pin the chosen fix: ``[tool.setuptools] py-modules = []`` plus a + no-op ``packages.find`` table that disables auto-discovery.""" + text = PYPROJECT.read_text() + assert "[tool.setuptools]" in text, ( + "missing [tool.setuptools] section in root pyproject.toml" + ) + assert "py-modules = []" in text, ( + "[tool.setuptools] should declare py-modules = [] to suppress " + "flat-layout auto-discovery" + ) + assert "[tool.setuptools.packages.find]" in text, ( + "missing [tool.setuptools.packages.find] section; cannot pin " + "no-package discovery for consumers" + ) From 90ad8d37d0e9a1432bf753dbb782ffb68d2cc3bc Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Sun, 9 Aug 2026 13:55:26 -0700 Subject: [PATCH 3/3] intake(phenokit-config-kit): fold from phenotype-sdk/lang/python/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intake of the genuinely-new phenokit-config-kit package from the phenotype-sdk Phase 3 decomposition. Per the rescued ORIGIN.md: 'Folded from KooshaPari/PhenoKits/libs/python/phenokit-config-kit into KooshaPari/phenotype-python-sdk/packages/phenokit-config-kit on 2026-06-20.' 'This package is intentionally kept distinct from packages/ phenotype-config because the source contains config-kit linting/ pre-commit/pytest scaffolds that are not file-equivalent to the existing SDK config package.' What was NOT brought in (corrected Phase 3 disposition): - lang/python/mcp/agentmcp/ — REJECTED Per agentmcp-hex/packages/agentmcp-hex/ORIGIN.md: 'agentmcp' was the pre-rename version. It was already extracted to packages/ agentmcp-hex/ on 2026-06-18 with module rename to 'agentmcp_hex' and version bump to 0.3.0. The SDK copy is the obsolete pre-rename version. cli.py differs by 1 line (import sys + trailing newline). Workspace integration: - Added packages/phenokit-config-kit to [tool.uv.workspace] members - Added phenokit-config-kit = { workspace = true } to [tool.uv.sources] - 45 files, 6390 insertions Bundle backup of original SDK content: .preservation-work/2026-08-08-sdk-decomp/phenotype-sdk/ phenotype-sdk-all.bundle (sha256: aca92b82...) Refs: - plans/dockets/N00-phenotype-sdk-decomp-operator-summary.md - plans/dockets/N03-phenotype-sdk-decomp-phase-3-python.md --- packages/phenokit-config-kit/ORIGIN.md | 5 + packages/phenokit-config-kit/README.md | 226 +++++++ .../config_kit/__init__.py | 16 + .../config_kit/pytest/__init__.py | 7 + .../config_kit/pytest/data/__init__.py | 13 + .../config_kit/pytest/data/factory.py | 225 +++++++ .../config_kit/pytest/fixtures/__init__.py | 18 + .../config_kit/pytest/fixtures/common.py | 226 +++++++ .../config_kit/pytest/fixtures/performance.py | 183 ++++++ .../config_kit/pytest/fixtures/security.py | 196 ++++++ .../config_kit/pytest/plugins/__init__.py | 15 + .../config_kit/pytest/plugins/architecture.py | 271 ++++++++ .../config_kit/pytest/plugins/performance.py | 166 +++++ .../config_kit/pytest/plugins/security.py | 243 +++++++ .../phenokit-config-kit/linting/.editorconfig | 40 ++ .../linting/.markdownlint.json | 45 ++ .../phenokit-config-kit/linting/README.md | 225 +++++++ .../linting/ci-cd/gatekeeper.toml | 93 +++ .../linting/ci-cd/scripts/quality-gate.sh | 243 +++++++ .../linting/git-hooks/commit-msg | 129 ++++ .../linting/git-hooks/pre-commit | 149 +++++ .../phenokit-config-kit/linting/linters.toml | 114 ++++ .../linting/pre-commit-hooks.yaml | 134 ++++ .../linting/pyproject.toml | 195 ++++++ .../phenokit-config-kit/pre-commit/basic.yaml | 32 + .../pre-commit/comprehensive.yaml | 292 +++++++++ .../pre-commit/security.yaml | 114 ++++ packages/phenokit-config-kit/pyproject.toml | 155 +++++ packages/phenokit-config-kit/pytest/README.md | 605 ++++++++++++++++++ packages/phenokit-config-kit/pytest/basic.ini | 27 + packages/phenokit-config-kit/pytest/ci.ini | 88 +++ .../pytest/comprehensive.ini | 140 ++++ .../pytest/data/__init__.py | 13 + .../pytest/data/factory.py | 225 +++++++ .../pytest/fixtures/__init__.py | 18 + .../pytest/fixtures/common.py | 226 +++++++ .../pytest/fixtures/performance.py | 183 ++++++ .../pytest/fixtures/security.py | 196 ++++++ .../phenokit-config-kit/pytest/parallel.ini | 65 ++ .../pytest/performance.ini | 64 ++ .../pytest/plugins/__init__.py | 15 + .../pytest/plugins/architecture.py | 271 ++++++++ .../pytest/plugins/performance.py | 166 +++++ .../pytest/plugins/security.py | 243 +++++++ .../phenokit-config-kit/pytest/security.ini | 75 +++ pyproject.toml | 6 + 46 files changed, 6396 insertions(+) create mode 100644 packages/phenokit-config-kit/ORIGIN.md create mode 100644 packages/phenokit-config-kit/README.md create mode 100644 packages/phenokit-config-kit/config_kit/__init__.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/__init__.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/data/__init__.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/data/factory.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py create mode 100644 packages/phenokit-config-kit/config_kit/pytest/plugins/security.py create mode 100644 packages/phenokit-config-kit/linting/.editorconfig create mode 100644 packages/phenokit-config-kit/linting/.markdownlint.json create mode 100644 packages/phenokit-config-kit/linting/README.md create mode 100644 packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml create mode 100755 packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh create mode 100755 packages/phenokit-config-kit/linting/git-hooks/commit-msg create mode 100755 packages/phenokit-config-kit/linting/git-hooks/pre-commit create mode 100644 packages/phenokit-config-kit/linting/linters.toml create mode 100644 packages/phenokit-config-kit/linting/pre-commit-hooks.yaml create mode 100644 packages/phenokit-config-kit/linting/pyproject.toml create mode 100644 packages/phenokit-config-kit/pre-commit/basic.yaml create mode 100644 packages/phenokit-config-kit/pre-commit/comprehensive.yaml create mode 100644 packages/phenokit-config-kit/pre-commit/security.yaml create mode 100644 packages/phenokit-config-kit/pyproject.toml create mode 100644 packages/phenokit-config-kit/pytest/README.md create mode 100644 packages/phenokit-config-kit/pytest/basic.ini create mode 100644 packages/phenokit-config-kit/pytest/ci.ini create mode 100644 packages/phenokit-config-kit/pytest/comprehensive.ini create mode 100644 packages/phenokit-config-kit/pytest/data/__init__.py create mode 100644 packages/phenokit-config-kit/pytest/data/factory.py create mode 100644 packages/phenokit-config-kit/pytest/fixtures/__init__.py create mode 100644 packages/phenokit-config-kit/pytest/fixtures/common.py create mode 100644 packages/phenokit-config-kit/pytest/fixtures/performance.py create mode 100644 packages/phenokit-config-kit/pytest/fixtures/security.py create mode 100644 packages/phenokit-config-kit/pytest/parallel.ini create mode 100644 packages/phenokit-config-kit/pytest/performance.ini create mode 100644 packages/phenokit-config-kit/pytest/plugins/__init__.py create mode 100644 packages/phenokit-config-kit/pytest/plugins/architecture.py create mode 100644 packages/phenokit-config-kit/pytest/plugins/performance.py create mode 100644 packages/phenokit-config-kit/pytest/plugins/security.py create mode 100644 packages/phenokit-config-kit/pytest/security.ini diff --git a/packages/phenokit-config-kit/ORIGIN.md b/packages/phenokit-config-kit/ORIGIN.md new file mode 100644 index 0000000..3087918 --- /dev/null +++ b/packages/phenokit-config-kit/ORIGIN.md @@ -0,0 +1,5 @@ +# Origin - phenokit-config-kit + +Folded from `KooshaPari/PhenoKits/libs/python/phenokit-config-kit` into `KooshaPari/phenotype-python-sdk/packages/phenokit-config-kit` on 2026-06-20. + +This package is intentionally kept distinct from `packages/phenotype-config` because the source contains config-kit linting/pre-commit/pytest scaffolds that are not file-equivalent to the existing SDK config package. diff --git a/packages/phenokit-config-kit/README.md b/packages/phenokit-config-kit/README.md new file mode 100644 index 0000000..b55e09f --- /dev/null +++ b/packages/phenokit-config-kit/README.md @@ -0,0 +1,226 @@ +# PhenoKit Config Kit + +A comprehensive configuration management package for Python projects providing pytest plugins, fixtures, linting configurations, and pre-commit hooks. + +## Features + +- **Pre-commit Configuration**: Multiple pre-commit hook configurations (basic, comprehensive, security) +- **Pytest Configuration**: Standardized testing configurations for different scenarios +- **Pytest Plugins**: Architecture, performance, and security testing plugins +- **Pytest Fixtures**: Common, performance, and security fixtures for testing +- **Linting Configuration**: Code quality and style enforcement configs +- **Test Data Factory**: Generate consistent test data across projects + +## Installation + +```bash +# Basic installation +pip install phenokit-config-kit + +# With all dependencies +pip install phenokit-config-kit[all] + +# With specific extras +pip install phenokit-config-kit[testing,linting] +``` + +## Quick Start + +### Using Pre-commit Configurations + +Copy the desired pre-commit configuration to your project: + +```bash +# Basic configuration +cp pre-commit/basic.yaml .pre-commit-config.yaml + +# Comprehensive configuration +cp pre-commit/comprehensive.yaml .pre-commit-config.yaml + +# Security-focused configuration +cp pre-commit/security.yaml .pre-commit-config.yaml +``` + +Install pre-commit hooks: +```bash +pre-commit install +``` + +### Using Pytest Configurations + +```bash +# Use basic configuration +pytest --ini=config-kit/pytest/basic.ini + +# Use comprehensive configuration +pytest --ini=config-kit/pytest/comprehensive.ini + +# Use CI configuration +pytest --ini=config-kit/pytest/ci.ini +``` + +### Using Pytest Plugins + +The pytest plugins are automatically registered when you install the package: + +```python +# Architecture testing (auto-added to test collection) +pytest --max-file-size=500 --max-complexity=10 + +# Performance testing +pytest -m performance --benchmark-threshold=1.0 + +# Security testing +pytest -m security +``` + +### Using Fixtures + +```python +import pytest +from config_kit.pytest.fixtures.common import mock_client, test_data +from config_kit.pytest.fixtures.performance import performance_monitor +from config_kit.pytest.fixtures.security import security_context + +def test_with_fixtures(mock_client, test_data): + result = mock_client.call_tool("test_tool", test_data["user"]) + assert result["success"] is True + +@pytest.mark.performance +def test_performance(performance_monitor): + with performance_monitor["measure"]("test_operation"): + result = expensive_operation() + assert result is not None +``` + +### Using Test Data Factory + +```python +from config_kit.pytest.data.factory import TestDataFactory + +factory = TestDataFactory(seed=42) + +# Generate user data +user_data = factory.user_data() + +# Generate organization data +org_data = factory.organization_data() + +# Generate project data +project_data = factory.project_data(organization_id="org-123") +``` + +## Configuration Files + +### Pre-commit Configurations +- `pre-commit/basic.yaml` - Basic pre-commit hooks +- `pre-commit/comprehensive.yaml` - Full linting and security suite +- `pre-commit/security.yaml` - Security-focused configuration + +### Pytest Configurations +- `pytest/basic.ini` - Basic pytest configuration +- `pytest/comprehensive.ini` - Enterprise-grade configuration +- `pytest/ci.ini` - CI/CD optimized +- `pytest/parallel.ini` - Parallel execution optimized +- `pytest/performance.ini` - Performance testing +- `pytest/security.ini` - Security testing + +### Linting Configurations +- `linting/pyproject.toml` - Python linter configs (Ruff, Black, isort, mypy) +- `linting/.editorconfig` - Editor configuration +- `linting/.markdownlint.json` - Markdown linting rules +- `linting/linters.toml` - Centralized linter configuration +- `linting/ci-cd/gatekeeper.toml` - Quality gate definitions + +## Pytest Plugins + +### Architecture Plugin + +Provides architecture fitness tests: +- File size validation +- Import boundary enforcement +- Dependency direction validation +- Cyclomatic complexity analysis + +### Performance Plugin + +Provides performance testing capabilities: +- Benchmark testing +- Memory profiling +- Performance regression detection + +### Security Plugin + +Provides security testing capabilities: +- Vulnerability scanning +- Forbidden imports check +- Hardcoded secrets detection + +## Git Hooks + +The package includes useful git hooks: + +### Pre-commit Hook +Validates staged files with linters (Ruff, Black, isort, mypy, bandit, etc.) + +### Commit-msg Hook +Validates commit messages for: +- Conventional commits format +- Proper capitalization +- Proper punctuation +- Minimum length requirements + +To use the git hooks: +```bash +cp linting/git-hooks/pre-commit .git/hooks/pre-commit +chmod +x .git/hooks/pre-commit + +cp linting/git-hooks/commit-msg .git/hooks/commit-msg +chmod +x .git/hooks/commit-msg +``` + +## Quality Gate Script + +Run comprehensive quality checks: + +```bash +bash linting/ci-cd/scripts/quality-gate.sh +``` + +This runs all configured linters and generates a `quality_gate_results.json` report. + +## Development + +### Project Structure + +``` +config-kit/ +├── pyproject.toml # Package configuration +├── README.md # This file +├── pre-commit/ # Pre-commit configurations +├── pytest/ # Pytest configurations, plugins, fixtures +│ ├── plugins/ # Pytest plugins +│ ├── fixtures/ # Pytest fixtures +│ └── data/ # Test data utilities +└── linting/ # Linting configurations and scripts + ├── ci-cd/ # CI/CD scripts + └── git-hooks/ # Git hooks +``` + +### Running Tests + +```bash +pytest --ini=pytest/comprehensive.ini +``` + +## License + +MIT License - See LICENSE file for details. + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run the quality gate script +5. Submit a pull request diff --git a/packages/phenokit-config-kit/config_kit/__init__.py b/packages/phenokit-config-kit/config_kit/__init__.py new file mode 100644 index 0000000..c3e4a18 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/__init__.py @@ -0,0 +1,16 @@ +""" +Config Kit - A comprehensive configuration management package for Python projects. + +This package provides: +- Pre-commit hook configurations +- Pytest configurations, plugins, and fixtures +- Linting configurations +- Test data factory +""" + +__version__ = "0.1.0" +__all__ = [ + "pytest", + "linting", + "pre_commit", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/__init__.py new file mode 100644 index 0000000..b90e163 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/__init__.py @@ -0,0 +1,7 @@ +""" +Pytest module for config-kit. + +This module provides pytest plugins, fixtures, and configurations. +""" + +__version__ = "0.1.0" diff --git a/packages/phenokit-config-kit/config_kit/pytest/data/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/data/__init__.py new file mode 100644 index 0000000..4269819 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/data/__init__.py @@ -0,0 +1,13 @@ +""" +Test data management for config-kit package. + +This module provides utilities for managing test data across different projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "TestDataFactory", + "TestDataGenerator", + "TestDataManager", + "TestDataValidator", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/data/factory.py b/packages/phenokit-config-kit/config_kit/pytest/data/factory.py new file mode 100644 index 0000000..10944f7 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/data/factory.py @@ -0,0 +1,225 @@ +""" +Test data factory for generating test data. + +This module provides a factory for generating test data across different projects. +""" + +import random +import string +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + + +@dataclass +class TestDataFactory: + """Factory for generating test data.""" + + seed: int | None = None + _random: random.Random = field(default_factory=random.Random) + + def __post_init__(self): + if self.seed is not None: + self._random.seed(self.seed) + + def random_string(self, length: int = 10, chars: str = None) -> str: + """Generate a random string.""" + if chars is None: + chars = string.ascii_letters + string.digits + return "".join(self._random.choices(chars, k=length)) + + def random_email(self, domain: str = "example.com") -> str: + """Generate a random email address.""" + username = self.random_string(8, string.ascii_lowercase + string.digits) + return f"{username}@{domain}" + + def random_uuid(self) -> str: + """Generate a random UUID.""" + return str(uuid.uuid4()) + + def random_int(self, min_val: int = 0, max_val: int = 100) -> int: + """Generate a random integer.""" + return self._random.randint(min_val, max_val) + + def random_float(self, min_val: float = 0.0, max_val: float = 1.0) -> float: + """Generate a random float.""" + return self._random.uniform(min_val, max_val) + + def random_choice(self, choices: list[Any]) -> Any: + """Choose a random item from a list.""" + return self._random.choice(choices) + + def random_choices(self, choices: list[Any], k: int = 1) -> list[Any]: + """Choose multiple random items from a list.""" + return self._random.choices(choices, k=k) + + def random_bool(self) -> bool: + """Generate a random boolean.""" + return self._random.choice([True, False]) + + def random_datetime(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random datetime.""" + if start is None: + start = datetime.now() - timedelta(days=365) + if end is None: + end = datetime.now() + + delta = end - start + random_seconds = self._random.randint(0, int(delta.total_seconds())) + return start + timedelta(seconds=random_seconds) + + def random_date(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random date (time set to 00:00:00).""" + dt = self.random_datetime(start, end) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + def user_data(self, **overrides) -> dict[str, Any]: + """Generate user test data.""" + data = { + "id": self.random_uuid(), + "username": self.random_string(8, string.ascii_lowercase + string.digits), + "email": self.random_email(), + "full_name": f"{self.random_string(6)} {self.random_string(8)}", + "roles": self.random_choices(["user", "admin", "moderator"], k=self.random_int(1, 3)), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def organization_data(self, **overrides) -> dict[str, Any]: + """Generate organization test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Organization {self.random_string(6)}", + "description": f"Test organization description {self.random_string(20)}", + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def project_data(self, organization_id: str = None, **overrides) -> dict[str, Any]: + """Generate project test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Project {self.random_string(6)}", + "description": f"Test project description {self.random_string(20)}", + "status": self.random_choice(["active", "inactive", "archived"]), + "organization_id": organization_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def document_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate document test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Document {self.random_string(6)}", + "content": f"Test document content {self.random_string(50)}", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def requirement_data(self, document_id: str = None, **overrides) -> dict[str, Any]: + """Generate requirement test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Requirement {self.random_string(6)}", + "description": f"Test requirement description {self.random_string(30)}", + "priority": self.random_choice(["low", "medium", "high", "critical"]), + "status": self.random_choice(["draft", "review", "approved", "rejected"]), + "document_id": document_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def test_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate test entity data.""" + data = { + "id": self.random_uuid(), + "name": f"Test {self.random_string(6)}", + "description": f"Test description {self.random_string(20)}", + "status": self.random_choice(["pending", "running", "passed", "failed", "skipped"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def relationship_data(self, source_id: str = None, target_id: str = None, **overrides) -> dict[str, Any]: + """Generate relationship test data.""" + data = { + "id": self.random_uuid(), + "source_id": source_id or self.random_uuid(), + "target_id": target_id or self.random_uuid(), + "relationship_type": self.random_choice(["parent", "child", "sibling", "related"]), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def workflow_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate workflow test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Workflow {self.random_string(6)}", + "description": f"Test workflow description {self.random_string(20)}", + "status": self.random_choice(["draft", "active", "paused", "completed"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def query_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate query test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Query {self.random_string(6)}", + "query": f"SELECT * FROM test_table WHERE id = '{self.random_uuid()}'", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def create_related_data(self, entity_type: str, count: int = 5, **overrides) -> list[dict[str, Any]]: + """Create related test data.""" + data = [] + for _ in range(count): + if entity_type == "user": + data.append(self.user_data(**overrides)) + elif entity_type == "organization": + data.append(self.organization_data(**overrides)) + elif entity_type == "project": + data.append(self.project_data(**overrides)) + elif entity_type == "document": + data.append(self.document_data(**overrides)) + elif entity_type == "requirement": + data.append(self.requirement_data(**overrides)) + elif entity_type == "test": + data.append(self.test_data(**overrides)) + elif entity_type == "relationship": + data.append(self.relationship_data(**overrides)) + elif entity_type == "workflow": + data.append(self.workflow_data(**overrides)) + elif entity_type == "query": + data.append(self.query_data(**overrides)) + else: + raise ValueError(f"Unknown entity type: {entity_type}") + return data diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py new file mode 100644 index 0000000..fe01c35 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/__init__.py @@ -0,0 +1,18 @@ +""" +Pytest fixtures for config-kit package. + +This module provides a collection of pytest fixtures that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "cache_fixture", + "database_fixture", + "message_broker_fixture", + "mock_client", + "performance_monitor", + "security_context", + "temp_dir", + "test_data", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py new file mode 100644 index 0000000..598cde9 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/common.py @@ -0,0 +1,226 @@ +""" +Common pytest fixtures for testing. + +This module provides commonly used fixtures across different projects. +""" + +import os +import shutil +import tempfile +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for testing.""" + temp_path = Path(tempfile.mkdtemp()) + try: + yield temp_path + finally: + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def mock_client() -> Mock: + """Create a mock client for testing.""" + client = Mock() + client.call_tool = Mock(return_value={"success": True, "data": {}}) + client.list_tools = Mock(return_value={"tools": []}) + client.list_resources = Mock(return_value={"resources": []}) + return client + + +@pytest.fixture +def test_data() -> dict[str, Any]: + """Provide test data for testing.""" + return { + "user": { + "id": "test-user-123", + "name": "Test User", + "email": "test@example.com", + "roles": ["user"], + }, + "organization": { + "id": "test-org-123", + "name": "Test Organization", + "description": "Test organization for testing", + }, + "project": { + "id": "test-project-123", + "name": "Test Project", + "status": "active", + "organization_id": "test-org-123", + }, + "document": { + "id": "test-doc-123", + "title": "Test Document", + "content": "Test document content", + "project_id": "test-project-123", + }, + } + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + import time + + import psutil + + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + } + + def measure(name: str): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + monitor_data["measure"] = measure + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def security_context() -> dict[str, Any]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + } + + +@pytest.fixture +def mock_http_client() -> Mock: + """Create a mock HTTP client for testing.""" + client = Mock() + client.get = Mock(return_value=Mock(status_code=200, json=dict)) + client.post = Mock(return_value=Mock(status_code=201, json=dict)) + client.put = Mock(return_value=Mock(status_code=200, json=dict)) + client.delete = Mock(return_value=Mock(status_code=204, json=dict)) + return client + + +@pytest.fixture +def mock_database() -> Mock: + """Create a mock database for testing.""" + db = Mock() + db.execute = Mock(return_value=Mock(rowcount=1)) + db.fetchone = Mock(return_value={}) + db.fetchall = Mock(return_value=[]) + db.commit = Mock() + db.rollback = Mock() + return db + + +@pytest.fixture +def mock_cache() -> Mock: + """Create a mock cache for testing.""" + cache = Mock() + cache.get = Mock(return_value=None) + cache.set = Mock(return_value=True) + cache.delete = Mock(return_value=True) + cache.clear = Mock(return_value=True) + return cache + + +@pytest.fixture +def mock_message_broker() -> Mock: + """Create a mock message broker for testing.""" + broker = Mock() + broker.publish = Mock(return_value=True) + broker.subscribe = Mock(return_value=Mock()) + broker.unsubscribe = Mock(return_value=True) + return broker + + +@pytest.fixture +def mock_file_system() -> Mock: + """Create a mock file system for testing.""" + fs = Mock() + fs.exists = Mock(return_value=True) + fs.read_text = Mock(return_value="test content") + fs.write_text = Mock(return_value=10) + fs.mkdir = Mock(return_value=True) + fs.rmdir = Mock(return_value=True) + fs.unlink = Mock(return_value=True) + return fs + + +@pytest.fixture +def mock_logger() -> Mock: + """Create a mock logger for testing.""" + logger = Mock() + logger.debug = Mock() + logger.info = Mock() + logger.warning = Mock() + logger.error = Mock() + logger.critical = Mock() + return logger + + +@pytest.fixture +def mock_config() -> Mock: + """Create a mock configuration for testing.""" + config = Mock() + config.get = Mock(return_value="default_value") + config.set = Mock(return_value=True) + config.has = Mock(return_value=True) + return config + + +@pytest.fixture +def mock_environment() -> Generator[dict[str, str], None, None]: + """Create a mock environment for testing.""" + original_env = os.environ.copy() + + test_env = { + "TEST_MODE": "true", + "TEST_DATABASE_URL": "sqlite:///:memory:", + "TEST_CACHE_URL": "memory://", + "TEST_MESSAGE_BROKER_URL": "memory://", + } + + os.environ.update(test_env) + + try: + yield test_env + finally: + os.environ.clear() + os.environ.update(original_env) diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py new file mode 100644 index 0000000..5c74ef9 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/performance.py @@ -0,0 +1,183 @@ +""" +Performance testing fixtures for pytest. + +This module provides fixtures specifically for performance testing. +""" + +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +from unittest.mock import Mock + +import psutil +import pytest + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + "threads": [], + } + + def measure(name: str, **kwargs): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + **kwargs, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + def start_thread_monitoring(): + """Start background thread monitoring.""" + def monitor(): + while True: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + time.sleep(0.1) + + thread = threading.Thread(target=monitor, daemon=True) + thread.start() + monitor_data["threads"].append(thread) + + monitor_data["measure"] = measure + monitor_data["start_thread_monitoring"] = start_thread_monitoring + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def benchmark_data() -> Generator[dict[str, Any], None, None]: + """Provide benchmark data for testing.""" + return { + "iterations": 1000, + "warmup_iterations": 100, + "timeout": 60.0, + "threshold": 1.0, + "results": [], + } + + +@pytest.fixture +def load_test_data() -> Generator[dict[str, Any], None, None]: + """Provide load test data for testing.""" + return { + "users": 10, + "duration": 60, + "ramp_up": 10, + "ramp_down": 10, + "requests_per_second": 100, + "concurrent_requests": 50, + } + + +@pytest.fixture +def memory_profiler() -> Generator[dict[str, Any], None, None]: + """Provide memory profiling capabilities.""" + profiler_data = { + "snapshots": [], + "baseline": None, + "current": None, + } + + def take_snapshot(name: str): + snapshot = { + "name": name, + "timestamp": time.time(), + "memory": psutil.Process().memory_info().rss / 1024 / 1024, + "memory_percent": psutil.Process().memory_percent(), + "cpu_percent": psutil.Process().cpu_percent(), + } + profiler_data["snapshots"].append(snapshot) + profiler_data["current"] = snapshot + return snapshot + + def set_baseline(): + profiler_data["baseline"] = take_snapshot("baseline") + + def get_memory_delta(): + if profiler_data["baseline"] and profiler_data["current"]: + return profiler_data["current"]["memory"] - profiler_data["baseline"]["memory"] + return 0 + + profiler_data["take_snapshot"] = take_snapshot + profiler_data["set_baseline"] = set_baseline + profiler_data["get_memory_delta"] = get_memory_delta + + yield profiler_data + + +@pytest.fixture +def performance_thresholds() -> dict[str, float]: + """Provide performance thresholds for testing.""" + return { + "max_duration": 1.0, # seconds + "max_memory": 100.0, # MB + "max_cpu": 80.0, # percent + "max_memory_delta": 50.0, # MB + } + + +@pytest.fixture +def mock_performance_client() -> Mock: + """Create a mock performance client for testing.""" + client = Mock() + client.benchmark = Mock(return_value={"duration": 0.5, "memory": 50.0}) + client.profile = Mock(return_value={"cpu": 10.0, "memory": 50.0}) + client.load_test = Mock(return_value={"throughput": 100.0, "latency": 0.1}) + return client + + +@contextmanager +def performance_context(name: str, threshold: float = 1.0): + """Context manager for performance monitoring.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded for {name}: {duration:.2f}s > {threshold}s") + + print(f"Performance: {name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +@pytest.fixture +def performance_context_factory(): + """Factory for creating performance contexts.""" + return performance_context diff --git a/packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py b/packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py new file mode 100644 index 0000000..e2f5d5e --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/fixtures/security.py @@ -0,0 +1,196 @@ +""" +Security testing fixtures for pytest. + +This module provides fixtures specifically for security testing. +""" + +import hashlib +import hmac +from collections.abc import Generator +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def security_context() -> Generator[dict[str, Any], None, None]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "request_id": "test-request-123", + } + + +@pytest.fixture +def mock_auth_client() -> Mock: + """Create a mock authentication client for testing.""" + client = Mock() + client.authenticate = Mock(return_value={"success": True, "user_id": "test-user-123"}) + client.authorize = Mock(return_value={"success": True, "permissions": ["read", "write"]}) + client.validate_token = Mock(return_value={"valid": True, "user_id": "test-user-123"}) + client.refresh_token = Mock(return_value={"success": True, "token": "new-token-123"}) + client.logout = Mock(return_value={"success": True}) + return client + + +@pytest.fixture +def mock_security_scanner() -> Mock: + """Create a mock security scanner for testing.""" + scanner = Mock() + scanner.scan_vulnerabilities = Mock(return_value={"vulnerabilities": []}) + scanner.scan_dependencies = Mock(return_value={"vulnerabilities": []}) + scanner.scan_secrets = Mock(return_value={"secrets": []}) + scanner.scan_code = Mock(return_value={"issues": []}) + return scanner + + +@pytest.fixture +def test_credentials() -> dict[str, str]: + """Provide test credentials for testing.""" + return { + "username": "testuser", + "password": "testpassword123", + "email": "test@example.com", + "api_key": "test-api-key-123", + "secret": "test-secret-123", + } + + +@pytest.fixture +def test_tokens() -> dict[str, str]: + """Provide test tokens for testing.""" + return { + "access_token": "test-access-token-123", + "refresh_token": "test-refresh-token-123", + "id_token": "test-id-token-123", + "csrf_token": "test-csrf-token-123", + } + + +@pytest.fixture +def test_hashes() -> dict[str, str]: + """Provide test hashes for testing.""" + test_string = "test-string-123" + return { + "md5": hashlib.md5(test_string.encode()).hexdigest(), + "sha1": hashlib.sha1(test_string.encode()).hexdigest(), + "sha256": hashlib.sha256(test_string.encode()).hexdigest(), + "sha512": hashlib.sha512(test_string.encode()).hexdigest(), + "hmac": hmac.new(b"secret-key", test_string.encode(), hashlib.sha256).hexdigest(), + } + + +@pytest.fixture +def test_encryption_data() -> dict[str, Any]: + """Provide test encryption data for testing.""" + return { + "plaintext": "test-plaintext-123", + "ciphertext": "encrypted-test-data-123", + "key": "test-encryption-key-123", + "iv": "test-initialization-vector-123", + "algorithm": "AES-256-GCM", + } + + +@pytest.fixture +def test_sql_injection_payloads() -> list[str]: + """Provide test SQL injection payloads for testing.""" + return [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "1' UNION SELECT * FROM users --", + "1'; INSERT INTO users (username, password) VALUES ('hacker', 'password'); --", + "1' AND (SELECT COUNT(*) FROM users) > 0 --", + ] + + +@pytest.fixture +def test_xss_payloads() -> list[str]: + """Provide test XSS payloads for testing.""" + return [ + "", + "", + "", + "javascript:alert('XSS')", + "", + ] + + +@pytest.fixture +def test_csrf_payloads() -> list[str]: + """Provide test CSRF payloads for testing.""" + return [ + "
", + "
", + "", + ] + + +@pytest.fixture +def test_path_traversal_payloads() -> list[str]: + """Provide test path traversal payloads for testing.""" + return [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", + "....//....//....//etc/passwd", + "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + ] + + +@pytest.fixture +def test_command_injection_payloads() -> list[str]: + """Provide test command injection payloads for testing.""" + return [ + "; ls -la", + "| cat /etc/passwd", + "& whoami", + "`id`", + "$(id)", + "; rm -rf /", + ] + + +@pytest.fixture +def security_headers() -> dict[str, str]: + """Provide security headers for testing.""" + return { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + } + + +@pytest.fixture +def mock_encryption_service() -> Mock: + """Create a mock encryption service for testing.""" + service = Mock() + service.encrypt = Mock(return_value="encrypted-data-123") + service.decrypt = Mock(return_value="decrypted-data-123") + service.generate_key = Mock(return_value="generated-key-123") + service.generate_iv = Mock(return_value="generated-iv-123") + return service + + +@pytest.fixture +def mock_audit_logger() -> Mock: + """Create a mock audit logger for testing.""" + logger = Mock() + logger.log_auth_success = Mock() + logger.log_auth_failure = Mock() + logger.log_permission_denied = Mock() + logger.log_security_event = Mock() + logger.log_data_access = Mock() + logger.log_data_modification = Mock() + return logger diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py new file mode 100644 index 0000000..1fe1989 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/__init__.py @@ -0,0 +1,15 @@ +""" +Pytest plugins for config-kit package. + +This module provides a collection of pytest plugins that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "ArchitecturePlugin", + "CoveragePlugin", + "PerformancePlugin", + "ReportingPlugin", + "SecurityPlugin", +] diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py new file mode 100644 index 0000000..ecd9519 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/architecture.py @@ -0,0 +1,271 @@ +""" +Architecture testing plugin for pytest. + +This plugin provides architecture fitness tests including: +- File size validation +- Import boundary enforcement +- Dependency direction validation +- Naming convention checks +- Cyclomatic complexity analysis +""" + +import ast +import os +from pathlib import Path + +import pytest + + +class ArchitecturePlugin: + """Pytest plugin for architecture fitness testing.""" + + def __init__(self, config): + self.config = config + self.max_file_size = config.getoption("--max-file-size", default=1000) + self.max_complexity = config.getoption("--max-complexity", default=10) + self.enforce_imports = config.getoption("--enforce-imports", default=True) + self.enforce_dependencies = config.getoption("--enforce-dependencies", default=True) + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add architecture tests to the test collection.""" + if not self.enforce_imports and not self.enforce_dependencies: + return + + # Add architecture tests + architecture_items = [] + + if self.enforce_imports: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_import_boundaries", + callobj=self._test_import_boundaries, + markers=[pytest.mark.architecture, pytest.mark.imports], + ), + ) + + if self.enforce_dependencies: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_dependency_direction", + callobj=self._test_dependency_direction, + markers=[pytest.mark.architecture, pytest.mark.dependencies], + ), + ) + + # Add file size tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_file_sizes", + callobj=self._test_file_sizes, + markers=[pytest.mark.architecture, pytest.mark.file_size], + ), + ) + + # Add complexity tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_cyclomatic_complexity", + callobj=self._test_cyclomatic_complexity, + markers=[pytest.mark.architecture, pytest.mark.complexity], + ), + ) + + items.extend(architecture_items) + + def _test_import_boundaries(self): + """Test that import boundaries are respected.""" + violations = [] + + # Define allowed import patterns + allowed_patterns = [ + r"^src\.", + r"^tests\.", + r"^conftest$", + ] + + # Check each Python file + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if not self._is_allowed_import(alias.name, allowed_patterns): + violations.append(f"{py_file}: {alias.name}") + elif isinstance(node, ast.ImportFrom): + if node.module and not self._is_allowed_import(node.module, allowed_patterns): + violations.append(f"{py_file}: {node.module}") + + if violations: + pytest.fail("Import boundary violations found:\n" + "\n".join(violations)) + + def _test_dependency_direction(self): + """Test that dependency direction is respected.""" + violations = [] + + # Define dependency rules + dependency_rules = { + "src.domain": [], # Domain should not depend on anything + "src.application": ["src.domain"], # Application can depend on domain + "src.adapters": ["src.domain", "src.application"], # Adapters can depend on domain and application + "src.infrastructure": ["src.domain", "src.application", "src.adapters"], # Infrastructure can depend on all + } + + for py_file in self._get_python_files(): + file_path = str(py_file) + if not file_path.startswith("src/"): + continue + + # Determine which layer this file belongs to + file_layer = self._get_file_layer(file_path) + if not file_layer: + continue + + # Check imports against dependency rules + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + import_layer = self._get_import_layer(node.module) + if import_layer and not self._is_allowed_dependency(file_layer, import_layer, dependency_rules): + violations.append(f"{py_file}: {file_layer} -> {import_layer} ({node.module})") + + if violations: + pytest.fail("Dependency direction violations found:\n" + "\n".join(violations)) + + def _test_file_sizes(self): + """Test that file sizes are within limits.""" + violations = [] + + for py_file in self._get_python_files(): + line_count = sum(1 for _ in open(py_file, encoding="utf-8")) + if line_count > self.max_file_size: + violations.append(f"{py_file}: {line_count} lines (max: {self.max_file_size})") + + if violations: + pytest.fail("File size violations found:\n" + "\n".join(violations)) + + def _test_cyclomatic_complexity(self): + """Test that cyclomatic complexity is within limits.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + complexity = self._calculate_complexity(node) + if complexity > self.max_complexity: + violations.append(f"{py_file}:{node.lineno} {node.name}: {complexity} (max: {self.max_complexity})") + + if violations: + pytest.fail("Cyclomatic complexity violations found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in os.walk("."): + # Skip certain directories + dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build"}] + + for file in files: + if file.endswith(".py"): + python_files.append(Path(root) / file) + + return python_files + + def _is_allowed_import(self, import_name: str, allowed_patterns: list[str]) -> bool: + """Check if an import is allowed based on patterns.""" + import re + return any(re.match(pattern, import_name) for pattern in allowed_patterns) + + def _get_file_layer(self, file_path: str) -> str | None: + """Determine which architectural layer a file belongs to.""" + if "src/domain" in file_path: + return "src.domain" + if "src/application" in file_path: + return "src.application" + if "src/adapters" in file_path: + return "src.adapters" + if "src/infrastructure" in file_path: + return "src.infrastructure" + return None + + def _get_import_layer(self, import_name: str) -> str | None: + """Determine which architectural layer an import belongs to.""" + if import_name.startswith("src.domain"): + return "src.domain" + if import_name.startswith("src.application"): + return "src.application" + if import_name.startswith("src.adapters"): + return "src.adapters" + if import_name.startswith("src.infrastructure"): + return "src.infrastructure" + return None + + def _is_allowed_dependency(self, from_layer: str, to_layer: str, rules: dict[str, list[str]]) -> bool: + """Check if a dependency from one layer to another is allowed.""" + allowed_deps = rules.get(from_layer, []) + return to_layer in allowed_deps + + def _calculate_complexity(self, node: ast.AST) -> int: + """Calculate cyclomatic complexity of a function.""" + complexity = 1 # Base complexity + + for child in ast.walk(node): + if isinstance(child, (ast.If, ast.While, ast.For, ast.AsyncFor)) or isinstance(child, ast.ExceptHandler): + complexity += 1 + elif isinstance(child, ast.BoolOp): + complexity += len(child.values) - 1 + + return complexity + + +def pytest_addoption(parser): + """Add command line options for architecture testing.""" + parser.addoption( + "--max-file-size", + type=int, + default=1000, + help="Maximum allowed lines per file", + ) + parser.addoption( + "--max-complexity", + type=int, + default=10, + help="Maximum allowed cyclomatic complexity", + ) + parser.addoption( + "--enforce-imports", + action="store_true", + default=True, + help="Enforce import boundaries", + ) + parser.addoption( + "--enforce-dependencies", + action="store_true", + default=True, + help="Enforce dependency direction", + ) + + +def pytest_configure(config): + """Configure the architecture plugin.""" + config.pluginmanager.register(ArchitecturePlugin(config), "architecture") diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py new file mode 100644 index 0000000..268ddc3 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/performance.py @@ -0,0 +1,166 @@ +""" +Performance testing plugin for pytest. + +This plugin provides performance testing capabilities including: +- Benchmark testing +- Memory profiling +- Performance regression detection +- Load testing utilities +""" + +import threading +import time +from collections.abc import Callable +from contextlib import contextmanager + +import psutil +import pytest + + +class PerformancePlugin: + """Pytest plugin for performance testing.""" + + def __init__(self, config): + self.config = config + self.benchmark_threshold = config.getoption("--benchmark-threshold", default=1.0) + self.memory_threshold = config.getoption("--memory-threshold", default=100) # MB + self.performance_data = {} + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_setup(self, item): + """Setup performance monitoring for each test.""" + if hasattr(item, "get_closest_marker") and item.get_closest_marker("performance"): + self.performance_data[item.nodeid] = { + "start_time": time.time(), + "start_memory": psutil.Process().memory_info().rss / 1024 / 1024, # MB + "peak_memory": 0, + } + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_teardown(self, item): + """Teardown performance monitoring for each test.""" + if item.nodeid in self.performance_data: + data = self.performance_data[item.nodeid] + data["end_time"] = time.time() + data["duration"] = data["end_time"] - data["start_time"] + data["end_memory"] = psutil.Process().memory_info().rss / 1024 / 1024 # MB + data["memory_delta"] = data["end_memory"] - data["start_memory"] + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_call(self, item): + """Monitor performance during test execution.""" + if item.nodeid in self.performance_data: + # Monitor memory usage during test execution + def monitor_memory(): + while item.nodeid in self.performance_data: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + self.performance_data[item.nodeid]["peak_memory"] = max(self.performance_data[item.nodeid]["peak_memory"], current_memory) + time.sleep(0.1) + + monitor_thread = threading.Thread(target=monitor_memory, daemon=True) + monitor_thread.start() + + def pytest_collection_modifyitems(self, config, items): + """Add performance validation tests.""" + performance_items = [] + + # Add benchmark validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_benchmark_performance", + callobj=self._test_benchmark_performance, + markers=[pytest.mark.performance, pytest.mark.benchmark], + ), + ) + + # Add memory usage validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_memory_usage", + callobj=self._test_memory_usage, + markers=[pytest.mark.performance, pytest.mark.memory], + ), + ) + + items.extend(performance_items) + + def _test_benchmark_performance(self): + """Test that benchmark performance meets thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "duration" in data and data["duration"] > self.benchmark_threshold: + violations.append(f"{test_id}: {data['duration']:.2f}s (threshold: {self.benchmark_threshold}s)") + + if violations: + pytest.fail("Benchmark performance violations found:\n" + "\n".join(violations)) + + def _test_memory_usage(self): + """Test that memory usage is within thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "peak_memory" in data and data["peak_memory"] > self.memory_threshold: + violations.append(f"{test_id}: {data['peak_memory']:.2f}MB (threshold: {self.memory_threshold}MB)") + + if violations: + pytest.fail("Memory usage violations found:\n" + "\n".join(violations)) + + +@contextmanager +def performance_monitor(test_name: str, threshold: float = 1.0): + """Context manager for monitoring performance of code blocks.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded: {duration:.2f}s > {threshold}s") + + print(f"Performance: {test_name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +def benchmark_test(func: Callable) -> Callable: + """Decorator for marking functions as benchmark tests.""" + return pytest.mark.performance(func) + + +def memory_test(func: Callable) -> Callable: + """Decorator for marking functions as memory tests.""" + return pytest.mark.memory(func) + + +def load_test(users: int = 10, duration: int = 60) -> Callable: + """Decorator for marking functions as load tests.""" + return pytest.mark.performance(pytest.mark.parametrize("load_users", [users])(func)) + + +def pytest_addoption(parser): + """Add command line options for performance testing.""" + parser.addoption( + "--benchmark-threshold", + type=float, + default=1.0, + help="Maximum allowed execution time for benchmark tests (seconds)", + ) + parser.addoption( + "--memory-threshold", + type=float, + default=100, + help="Maximum allowed memory usage (MB)", + ) + + +def pytest_configure(config): + """Configure the performance plugin.""" + config.pluginmanager.register(PerformancePlugin(config), "performance") diff --git a/packages/phenokit-config-kit/config_kit/pytest/plugins/security.py b/packages/phenokit-config-kit/config_kit/pytest/plugins/security.py new file mode 100644 index 0000000..b1d9690 --- /dev/null +++ b/packages/phenokit-config-kit/config_kit/pytest/plugins/security.py @@ -0,0 +1,243 @@ +""" +Security testing plugin for pytest. + +This plugin provides security testing capabilities including: +- Security vulnerability scanning +- Authentication testing +- Authorization testing +- Input validation testing +- Security compliance checking +""" + +import ast +import re +from pathlib import Path + +import pytest + + +class SecurityPlugin: + """Pytest plugin for security testing.""" + + def __init__(self, config): + self.config = config + self.security_rules = self._load_security_rules() + self.vulnerability_patterns = self._load_vulnerability_patterns() + + def _load_security_rules(self) -> dict[str, list[str]]: + """Load security rules configuration.""" + return { + "forbidden_imports": [ + "pickle", + "marshal", + "shelve", + "dbm", + "sqlite3", + "subprocess", + "os.system", + "eval", + "exec", + "compile", + ], + "forbidden_functions": [ + "eval", + "exec", + "compile", + "input", + "raw_input", + "reload", + "__import__", + ], + "required_imports": [ + "hashlib", + "secrets", + "hmac", + ], + "password_patterns": [ + r"password\s*=\s*['\"][^'\"]+['\"]", + r"passwd\s*=\s*['\"][^'\"]+['\"]", + r"pwd\s*=\s*['\"][^'\"]+['\"]", + ], + "hardcoded_secrets": [ + r"api_key\s*=\s*['\"][^'\"]+['\"]", + r"secret\s*=\s*['\"][^'\"]+['\"]", + r"token\s*=\s*['\"][^'\"]+['\"]", + r"key\s*=\s*['\"][^'\"]+['\"]", + ], + } + + def _load_vulnerability_patterns(self) -> dict[str, str]: + """Load vulnerability detection patterns.""" + return { + "sql_injection": r"(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER).*\+.*%", + "xss": r".*", + "path_traversal": r"\.\./", + "command_injection": r"(os\.system|subprocess\.call|subprocess\.run)", + "unsafe_deserialization": r"(pickle\.loads|marshal\.loads)", + "weak_crypto": r"(md5|sha1|des|rc4)", + "hardcoded_credentials": r"(username|password|api_key|secret)\s*=\s*['\"][^'\"]+['\"]", + } + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add security validation tests.""" + security_items = [] + + # Add vulnerability scanning + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_vulnerability_scan", + callobj=self._test_vulnerability_scan, + markers=[pytest.mark.security, pytest.mark.vulnerability], + ), + ) + + # Add forbidden imports check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_forbidden_imports", + callobj=self._test_forbidden_imports, + markers=[pytest.mark.security, pytest.mark.imports], + ), + ) + + # Add hardcoded secrets check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_hardcoded_secrets", + callobj=self._test_hardcoded_secrets, + markers=[pytest.mark.security, pytest.mark.secrets], + ), + ) + + # Add password security check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_password_security", + callobj=self._test_password_security, + markers=[pytest.mark.security, pytest.mark.passwords], + ), + ) + + items.extend(security_items) + + def _test_vulnerability_scan(self): + """Scan code for common vulnerabilities.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for vuln_type, pattern in self.vulnerability_patterns.items(): + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: {vuln_type} - {matches}") + + if violations: + pytest.fail("Security vulnerabilities found:\n" + "\n".join(violations)) + + def _test_forbidden_imports(self): + """Check for forbidden imports that could be security risks.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{alias.name}'") + elif isinstance(node, ast.ImportFrom): + if node.module in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{node.module}'") + + if violations: + pytest.fail("Forbidden imports found:\n" + "\n".join(violations)) + + def _test_hardcoded_secrets(self): + """Check for hardcoded secrets in the code.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["hardcoded_secrets"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Hardcoded secret - {matches}") + + if violations: + pytest.fail("Hardcoded secrets found:\n" + "\n".join(violations)) + + def _test_password_security(self): + """Check for insecure password handling.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["password_patterns"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Insecure password handling - {matches}") + + if violations: + pytest.fail("Insecure password handling found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in Path().rglob("*.py"): + # Skip certain directories + if any(skip in str(root) for skip in (".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build")): + continue + python_files.append(root) + + return python_files + + +def security_test(func: Callable) -> Callable: + """Decorator for marking functions as security tests.""" + return pytest.mark.security(func) + + +def auth_test(func: Callable) -> Callable: + """Decorator for marking functions as authentication tests.""" + return pytest.mark.auth(func) + + +def injection_test(func: Callable) -> Callable: + """Decorator for marking functions as injection tests.""" + return pytest.mark.injection(func) + + +def xss_test(func: Callable) -> Callable: + """Decorator for marking functions as XSS tests.""" + return pytest.mark.xss(func) + + +def csrf_test(func: Callable) -> Callable: + """Decorator for marking functions as CSRF tests.""" + return pytest.mark.csrf(func) + + +def sql_injection_test(func: Callable) -> Callable: + """Decorator for marking functions as SQL injection tests.""" + return pytest.mark.sql_injection(func) + + +def pytest_configure(config): + """Configure the security plugin.""" + config.pluginmanager.register(SecurityPlugin(config), "security") diff --git a/packages/phenokit-config-kit/linting/.editorconfig b/packages/phenokit-config-kit/linting/.editorconfig new file mode 100644 index 0000000..0088cc5 --- /dev/null +++ b/packages/phenokit-config-kit/linting/.editorconfig @@ -0,0 +1,40 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +# Unix-style newlines with a newline ending every file +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +# Python files +[*.py] +indent_size = 4 +max_line_length = 88 + +# YAML files +[*.{yaml,yml}] +indent_size = 2 +max_line_length = 88 + +# Markdown files +[*.md] +trim_trailing_whitespace = false +max_line_length = 88 + +# JSON files +[*.json] +indent_size = 2 + +# Shell scripts +[*.sh] +indent_size = 4 + +# Dockerfile +[Dockerfile] +indent_size = 4 diff --git a/packages/phenokit-config-kit/linting/.markdownlint.json b/packages/phenokit-config-kit/linting/.markdownlint.json new file mode 100644 index 0000000..9cee890 --- /dev/null +++ b/packages/phenokit-config-kit/linting/.markdownlint.json @@ -0,0 +1,45 @@ +{ + "default": true, + "MD001": false, + "MD013": { + "line_length": 88 + }, + "MD024": { + "allow_different_nesting": true + }, + "MD033": false, + "MD034": false, + "MD036": false, + "MD037": false, + "MD038": false, + "MD039": false, + "MD040": false, + "MD041": false, + "MD042": false, + "MD043": false, + "MD044": false, + "MD045": false, + "MD046": false, + "MD047": false, + "MD048": { + "level": "warning" + }, + "MD049": { + "level": "warning" + }, + "MD050": { + "level": "warning" + }, + "MD051": { + "level": "warning" + }, + "MD052": { + "level": "warning" + }, + "MD053": { + "level": "warning" + }, + "MD054": { + "level": "warning" + } +} diff --git a/packages/phenokit-config-kit/linting/README.md b/packages/phenokit-config-kit/linting/README.md new file mode 100644 index 0000000..6d2570a --- /dev/null +++ b/packages/phenokit-config-kit/linting/README.md @@ -0,0 +1,225 @@ +# Linting Configurations for config-kit + +This directory contains comprehensive linting configurations for the config-kit package, including all major linting tools, pre-commit hooks, and CI/CD integration. + +## Overview + +The linting configuration includes: + +- **Code Style**: Black, isort, Ruff +- **Type Checking**: MyPy +- **Security**: Bandit, Safety, detect-secrets +- **Documentation**: pydocstyle, interrogate, docformatter +- **Performance**: Radon (complexity) +- **Quality**: Vulture (dead code), PyUp (upgrade checks) +- **YAML/Markdown**: yamllint, markdownlint +- **Shell**: shellcheck +- **Pre-commit Hooks**: Automated checks on commit +- **CI/CD Integration**: Quality gates and automated checks + +## Directory Structure + +``` +linting/ +├── pyproject.toml # Main linting configuration +├── linters.toml # Centralized linter configuration +├── .editorconfig # Editor configuration +├── .markdownlint.json # Markdown linting rules +├── pre-commit-hooks.yaml # Pre-commit configuration +├── ci-cd/ +│ ├── gatekeeper.toml # Quality gate definitions +│ └── scripts/ +│ └── quality-gate.sh # Comprehensive quality check script +├── git-hooks/ # Custom git hooks +│ ├── pre-commit # Custom pre-commit hook +│ └── commit-msg # Commit message validation +└── README.md # This file +``` + +## Usage + +### 1. Basic Linting + +Run individual linters: + +```bash +# Code formatting +black . +black --check # Check without modifying + +# Import sorting +isort . +isort --check-only + +# Ruff (all-in-one) +ruff check . +ruff check --fix # Fix auto-fixable issues +ruff format . # Format code + +# Type checking +mypy src/ + +# Security scanning +bandit -r src/ + +# Documentation +pydocstyle src/ +interrogate src/ +``` + +### 2. Pre-commit Hooks + +Set up pre-commit hooks: + +```bash +# Install pre-commit +pip install pre-commit + +# Install hooks +pre-commit install + +# Run hooks manually on all files +pre-commit run --all-files +``` + +### 3. Comprehensive Quality Gate + +Run all quality checks: + +```bash +# Run comprehensive quality gate +./linting/ci-cd/scripts/quality-gate.sh + +# Run specific sections +./linting/ci-cd/scripts/quality-gate.sh 2>&1 | grep "LINTING CHECKS" +``` + +### 4. CI/CD Integration + +The quality gate configuration can be integrated into CI/CD pipelines: + +```yaml +# Example GitHub Actions workflow +- name: Run quality gate + run: ./linting/ci-cd/scripts/quality-gate.sh + +- name: Upload quality reports + uses: actions/upload-artifact@v3 + if: always() + with: + name: quality-reports + path: | + quality_gate_results.json + bandit-report.json + safety-report.json +``` + +## Configuration Details + +### pyproject.toml + +Contains configuration for all Python-based linters: + +- **Ruff**: Fast Python linter and formatter +- **Black**: Code formatter +- **isort**: Import sorter +- **MyPy**: Type checker +- **Bandit**: Security linter +- **Safety**: Vulnerability scanner +- **yamllint**: YAML linter +- **markdownlint**: Markdown linter +- **pydocstyle**: Docstring checker +- **interrogate**: Coverage checker for docstrings +- **vulture**: Dead code finder +- **radon**: Code complexity analyzer + +### Pre-commit Hooks + +The pre-commit configuration includes: + +1. **Automatic formatting**: Black, isort, Ruff +2. **Type checking**: MyPy +3. **Security scanning**: Bandit +4. **Documentation**: pydocstyle, interrogate +5. **File-specific**: yamllint, markdownlint, shellcheck + +### Quality Gates + +The quality gate system defines thresholds for: + +- **Code Coverage**: 80% minimum +- **Documentation**: 80% docstring coverage +- **Security**: Zero critical/high vulnerabilities +- **Complexity**: Max 10 cyclomatic complexity +- **File Size**: Max 50KB per file +- **Dependencies**: No known vulnerabilities + +## Customization + +### Adding New Linters + +1. Add linter to `pyproject.toml` +2. Update `pre-commit-hooks.yaml` +3. Add checks to `quality-gate.sh` +4. Update `gatekeeper.toml` + +### Adjusting Rules + +Edit the respective configuration files: + +- **Python linters**: `pyproject.toml` +- **Markdown**: `.markdownlint.json` +- **YAML**: `yamllint` configuration in `pyproject.toml` +- **Quality gates**: `gatekeeper.toml` + +### Excluding Files + +Use `exclude` patterns in configurations: + +```toml +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG001"] +"__init__.py" = ["F401"] +``` + +## Troubleshooting + +### Common Issues + +1. **Pre-commit hooks not running**: Ensure `pre-commit install` was run +2. **Linter dependencies missing**: Run `pip install -e .` +3. **Quality gate failing**: Check `quality_gate_results.json` for details +4. **Configuration conflicts**: Resolve by updating specific tool configurations + +### Debug Mode + +Run linters with verbose output: + +```bash +# Ruff verbose +ruff check --verbose + +# Black verbose +black --verbose + +# MyPy verbose +mypy --verbose +``` + +## Maintenance + +- Update linter versions regularly in `pre-commit-hooks.yaml` +- Monitor new rule releases in `pyproject.toml` +- Review quality gate thresholds in `gatekeeper.toml` +- Keep dependencies updated with `pip-compile` + +## Integration with Other Projects + +This linting configuration can be used as a template for other projects by: + +1. Copying the `linting/` directory +2. Adjusting paths and exclusions +3. Updating quality gate thresholds as needed +4. Integrating with project-specific requirements + +For full integration, ensure the configuration is included in the package data and properly referenced in each project's pyproject.toml. diff --git a/packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml b/packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml new file mode 100644 index 0000000..cb77fa8 --- /dev/null +++ b/packages/phenokit-config-kit/linting/ci-cd/gatekeeper.toml @@ -0,0 +1,93 @@ +# CI/CD Gatekeeper Configuration for config-kit +# Defines quality gates that must pass before merging/merging + +[gates.linting] +enabled = true +required = ["ruff", "black", "isort", "mypy"] +failure_threshold = 0 + +[gates.security] +enabled = true +required = ["bandit", "safety"] +failure_threshold = 0 +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.documentation] +enabled = true +required = ["pydocstyle", "interrogate"] +failure_threshold = 0 +minimum_coverage = 80 +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.performance] +enabled = false +required = [] +failure_threshold = 0 +max_complexity = 10 +max_file_size = 50000 + +[gates.test_coverage] +enabled = true +required_coverage = 80 +line_coverage = 80 +branch_coverage = 75 +function_coverage = 80 +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.dependency_vulnerability] +enabled = true +fail_on_vulnerability = true +severity_threshold = "medium" +exclude_patterns = ["**/test_*.py", "**/tests/**"] + +[gates.code_patterns] +enabled = true +forbidden_patterns = [ + "import (os\.system|subprocess\.call|subprocess\.run|subprocess\.Popen)", + "eval\(", + "exec\(", + "print\(", + "TODO: FIX ME", + "FIXME:", + "HACK:", + "PRAGMA: disable" +] +required_patterns = [ + "assert ", + "raise ", + "import typing" +] +failure_threshold = 0 + +[gates.license_compliance] +enabled = true +allowed_licenses = ["MIT", "Apache-2.0", "BSD-3-Clause", "PSF", "CC0-1.0"] +blacklisted_packages = [] +failure_threshold = 0 + +[gates.metrics] +enabled = true +max_lines_per_file = 500 +max_functions_per_file = 20 +max_arguments_per_function = 7 +max_depth_nesting = 4 +failure_threshold = 0 + +[gates.git_checks] +enabled = true +no_merge_conflicts = true +no_private_keys = true +no_secrets = true +failure_threshold = 0 + +[reporting] +format = "json" +output_file = "quality_gate_report.json" +email_on_failure = false +slack_webhook = "" +require_all_gates = false + +[environments] +development = ["linting", "security"] +staging = ["linting", "security", "documentation", "test_coverage", "dependency_vulnerability"] +production = ["all"] diff --git a/packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh b/packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh new file mode 100755 index 0000000..511fe62 --- /dev/null +++ b/packages/phenokit-config-kit/linting/ci-cd/scripts/quality-gate.sh @@ -0,0 +1,243 @@ +#!/bin/bash + +# Quality Gate Script for config-kit +# This script runs all quality checks and exits with non-zero if any fail + +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Global variables +PASSED=0 +FAILED=0 +TOTAL=0 +RESULTS_FILE="quality_gate_results.json" + +# Function to print colored output +print_header() { + echo -e "${BLUE}[$1]${NC} $2" +} + +print_success() { + echo -e "${GREEN}✓ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠ $1${NC}" +} + +print_error() { + echo -e "${RED}✗ $1${NC}" +} + +# Function to initialize results JSON +init_results() { + cat > "$RESULTS_FILE" << EOF +{ + "timestamp": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")", + "project": "config-kit", + "checks": {} +} +EOF +} + +# Function to add check result to JSON +add_check_result() { + local check_name="$1" + local status="$2" + local message="$3" + local details="$4" + + # Use jq to update the JSON file + if command_exists jq; then + jq --arg name "$check_name" --arg status "$status" --arg message "$message" --arg details "$details" ' + .checks[$name] = { + "status": $status, + "message": $message, + "details": $details, + "timestamp": now + }' "$RESULTS_FILE" > temp.json && mv temp.json "$RESULTS_FILE" + fi +} + +# Function to run check and count results +run_check() { + local check_name="$1" + local check_command="$2" + local success_message="$3" + local failure_message="$4" + + TOTAL=$((TOTAL + 1)) + print_header "RUNNING" "$check_name" + + if eval "$check_command" >/dev/null 2>&1; then + PASSED=$((PASSED + 1)) + print_success "$success_message" + add_check_result "$check_name" "passed" "$success_message" "" + return 0 + else + FAILED=$((FAILED + 1)) + print_error "$failure_message" + add_check_result "$check_name" "failed" "$failure_message" "" + return 1 + fi +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Linting checks +run_linting_checks() { + print_header "SECTION" "Linting Checks" + + run_check "Ruff" "ruff check --exit-zero" "Ruff passed" "Ruff failed" + run_check "Black formatting" "black --check ." "Black format passed" "Black format failed" + run_check "Import sorting" "isort --check-only ." "isort passed" "isort failed" + run_check "Type checking" "mypy --ignore-missing-imports src/" "Mypy passed" "Mypy failed" +} + +# Security checks +run_security_checks() { + print_header "SECTION" "Security Checks" + + run_check "Bandit security scan" "bandit -r src -f json -o bandit-report.json" "Bandit passed" "Bandit failed" + run_check "Safety vulnerability check" "safety check --json --output safety-report.json" "Safety passed" "Safety failed" + run_check "Secrets detection" "detect-secrets scan --baseline .secrets.baseline" "Secrets check passed" "Secrets check failed" +} + +# Documentation checks +run_documentation_checks() { + print_header "SECTION" "Documentation Checks" + + run_check "Docstring style" "pydocstyle --convention=google src/" "Pydocstyle passed" "Pydocstyle failed" + run_check "Coverage check" "pytest --cov=src --cov-report=term-missing --cov-fail-under=80" "Coverage check passed" "Coverage check failed" +} + +# Performance checks +run_performance_checks() { + print_header "SECTION" "Performance Checks" + + run_check "Complexity check" "radon cc src/ -nb -a" "Complexity check passed" "Complexity check failed" + run_check "Maintainability index" "radon cc src/ -nb" "Maintainability check passed" "Maintainability check failed" +} + +# Dependency checks +run_dependency_checks() { + print_header "SECTION" "Dependency Checks" + + run_check "Dependency vulnerability" "pip-audit" "Dependency audit passed" "Dependency audit failed" + run_check "Outdated dependencies" "pip list --outdated --format=json --timeout 60" "Dependency check passed" "Dependency check failed" +} + +# Code quality metrics +run_quality_metrics() { + print_header "SECTION" "Quality Metrics" + + # Check file sizes + local large_files=$(find src/ -name "*.py" -size +50k) + if [ -n "$large_files" ]; then + print_warning "Large files found: $large_files" + add_check_result "File sizes" "warning" "Large files detected" "$large_files" + TOTAL=$((TOTAL + 1)) + FAILED=$((FAILED + 1)) + else + PASSED=$((PASSED + 1)) + print_success "All files are under size limit" + add_check_result "File sizes" "passed" "All files under size limit" "" + fi + + # Check cyclomatic complexity + local complex_files=$(radon cc src/ -nb -a | grep -v "F" | awk '$2 > 10 {print $1}') + if [ -n "$complex_files" ]; then + print_warning "Complex files found: $complex_files" + add_check_result "Complexity" "warning" "Complex files detected" "$complex_files" + TOTAL=$((TOTAL + 1)) + FAILED=$((FAILED + 1)) + else + PASSED=$((PASSED + 1)) + print_success "All files have acceptable complexity" + add_check_result "Complexity" "passed" "All files have acceptable complexity" "" + fi +} + +# Cleanup function +cleanup() { + # Remove temporary files + rm -f temp.json bandit-report.json safety-report.json +} + +# Main execution +main() { + # Set up cleanup trap + trap cleanup EXIT + + # Initialize results + init_results + + echo "Starting Quality Gate checks..." + echo "==================================" + + # Run all checks + run_linting_checks + run_security_checks + run_documentation_checks + run_performance_checks + run_dependency_checks + run_quality_metrics + + # Summary + echo "" + echo "==================================" + echo "Quality Gate Summary" + echo "==================================" + echo "Total checks: $TOTAL" + echo "Passed: $PASSED" + echo "Failed: $FAILED" + + if [ $FAILED -eq 0 ]; then + echo "" + print_success "All checks passed! Quality gate passed." + exit 0 + else + echo "" + print_error "$FAILED checks failed. Quality gate failed." + exit 1 + fi +} + +# Check if we're in the right directory +if [ ! -f "pyproject.toml" ] || [ ! -d "src" ]; then + print_error "This script must be run from the config-kit root directory" + exit 1 +fi + +# Check for required commands +if ! command_exists ruff; then + print_error "ruff is required but not installed" + exit 1 +fi + +if ! command_exists black; then + print_error "black is required but not installed" + exit 1 +fi + +if ! command_exists isort; then + print_error "isort is required but not installed" + exit 1 +fi + +if ! command_exists mypy; then + print_error "mypy is required but not installed" + exit 1 +fi + +# Run main function +main "$@" diff --git a/packages/phenokit-config-kit/linting/git-hooks/commit-msg b/packages/phenokit-config-kit/linting/git-hooks/commit-msg new file mode 100755 index 0000000..c081969 --- /dev/null +++ b/packages/phenokit-config-kit/linting/git-hooks/commit-msg @@ -0,0 +1,129 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_success() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +# Get the commit message file +COMMIT_MSG_FILE=$1 + +# Check if commit message file exists +if [ ! -f "$COMMIT_MSG_FILE" ]; then + print_error "Commit message file not found: $COMMIT_MSG_FILE" + exit 1 +fi + +# Read the commit message +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +# Check for common patterns that should not be in commit messages +check_commit_message() { + local msg="$1" + + # Check for empty message + if [ -z "$msg" ] || [ -z "$(echo "$msg" | xargs)" ]; then + print_error "Commit message cannot be empty" + return 1 + fi + + # Check for WIP (work in progress) commits + if echo "$msg" | grep -qi "^wip\|^work in progress\|^draft\|^update"; then + print_error "Commit message appears to be a work in progress" + return 1 + fi + + # Check for very short messages (less than 5 characters) + if [ ${#msg} -lt 5 ]; then + print_error "Commit message is too short (minimum 5 characters)" + return 1 + fi + + # Check for all caps messages (might be a typo) + if echo "$msg" | grep -q "^[A-Z ]*$" && [ ${#msg} -gt 20 ]; then + print_error "Commit message appears to be in all caps (possible typo)" + return 1 + fi + + # Check for common forbidden words + if echo "$msg" | grep -qi "\btest\b\|fixme\b\|hack\b\|temporary\b\|temp\b"; then + print_error "Commit message contains discouraged words" + return 1 + fi + + # Check for proper capitalization (first letter should be uppercase) + if ! echo "$msg" | grep -q "^[A-Z]"; then + print_error "Commit message should start with a capital letter" + return 1 + fi + + # Check for proper punctuation (should end with period, question mark, or exclamation) + if ! echo "$msg" | grep -q "[.!?]$"; then + print_error "Commit message should end with proper punctuation" + return 1 + fi + + return 0 +} + +# Check if commit message follows conventional commits format +check_conventional_commits() { + local msg="$1" + + # Check for conventional commits format + if echo "$msg" | grep -qE "^(feat|fix|docs|style|refactor|perf|test|chore|build|ci|revert|wip)(\([^)]+\))?: "; then + return 0 + fi + + # If not conventional commits, check for minimum quality + if check_commit_message "$msg"; then + return 0 + fi + + return 1 +} + +# Main execution +main() { + print_success "Validating commit message..." + + # Check commit message + if check_conventional_commits "$COMMIT_MSG"; then + print_success "Commit message is valid" + exit 0 + else + print_error "Invalid commit message format" + echo "" + echo "Please use conventional commits format:" + echo " feat: add new feature" + echo " fix: resolve issue" + echo " docs: update documentation" + echo " style: code formatting" + echo " refactor: code refactoring" + echo " test: add tests" + echo " chore: maintenance tasks" + echo "" + echo "Or at minimum:" + echo " - Start with a capital letter" + echo " - End with proper punctuation" + echo " - Be descriptive and meaningful" + echo "" + exit 1 + fi +} + +# Run main function +main "$@" diff --git a/packages/phenokit-config-kit/linting/git-hooks/pre-commit b/packages/phenokit-config-kit/linting/git-hooks/pre-commit new file mode 100755 index 0000000..0db1780 --- /dev/null +++ b/packages/phenokit-config-kit/linting/git-hooks/pre-commit @@ -0,0 +1,149 @@ +#!/bin/bash + +# Exit immediately if a command exits with a non-zero status +set -e + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Get staged Python files +get_staged_py_files() { + git diff --cached --name-only --diff-filter=AM -- "*.py" "*.pyi" +} + +# Get staged files for other linters +get_staged_files() { + git diff --cached --name-only --diff-filter=AM +} + +# Lint staged Python files +lint_python_files() { + local files="$1" + if [ -z "$files" ]; then + print_status "No Python files to lint" + return 0 + fi + + print_status "Linting Python files..." + + # Run Ruff + if command_exists ruff; then + print_status "Running Ruff..." + echo "$files" | xargs ruff check --fix + echo "$files" | xargs ruff format --check + else + print_warning "Ruff not found, skipping..." + fi + + # Run Black + if command_exists black; then + print_status "Running Black..." + echo "$files" | xargs black --check + else + print_warning "Black not found, skipping..." + fi + + # Run isort + if command_exists isort; then + print_status "Running isort..." + echo "$files" | xargs isort --check-only + else + print_warning "isort not found, skipping..." + fi + + # Run mypy + if command_exists mypy; then + print_status "Running mypy..." + echo "$files" | xargs mypy + else + print_warning "mypy not found, skipping..." + fi +} + +# Lint all staged files +lint_all_files() { + local files="$1" + if [ -z "$files" ]; then + print_status "No files to lint" + return 0 + fi + + # Run bandit (security linting) + if command_exists bandit; then + print_status "Running bandit..." + echo "$files" | xargs bandit -r -f json + else + print_warning "bandit not found, skipping..." + fi + + # Run yamllint + if command_exists yamllint; then + print_status "Running yamllint..." + echo "$files" | xargs yamllint + else + print_warning "yamllint not found, skipping..." + fi + + # Run markdownlint + if command_exists markdownlint; then + print_status "Running markdownlint..." + echo "$files" | xargs markdownlint + else + print_warning "markdownlint not found, skipping..." + fi + + # Run shellcheck + if command_exists shellcheck; then + print_status "Running shellcheck..." + echo "$files" | xargs shellcheck + else + print_warning "shellcheck not found, skipping..." + fi +} + +# Main execution +main() { + print_status "Starting pre-commit linting..." + + # Get staged files + staged_py_files=$(get_staged_py_files) + staged_files=$(get_staged_files) + + # If no files are staged, exit + if [ -z "$staged_files" ]; then + print_status "No files staged, skipping pre-commit hooks" + exit 0 + fi + + # Lint Python files + lint_python_files "$staged_py_files" + + # Lint all files (other linters) + lint_all_files "$staged_files" + + print_status "Pre-commit linting completed successfully!" +} + +# Run main function +main "$@" diff --git a/packages/phenokit-config-kit/linting/linters.toml b/packages/phenokit-config-kit/linting/linters.toml new file mode 100644 index 0000000..19a0ef5 --- /dev/null +++ b/packages/phenokit-config-kit/linting/linters.toml @@ -0,0 +1,114 @@ +[linters] +enabled = [ + "ruff", + "black", + "isort", + "mypy", + "bandit", + "safety", + "yamllint", + "markdownlint", + "pydocstyle", + "docformatter", + "interrogate", + "vulture", + "radon", + "pyupgrade" +] + +[linters.ruff] +command = "ruff check" +fix_command = "ruff check --fix" +output_format = "github" +target_version = "py311" +line_length = 88 + +[linters.black] +command = "black --check" +fix_command = "black" +target_version = "py311" +line_length = 88 + +[linters.isort] +command = "isort --check-only" +fix_command = "isort" +profile = "black" +multi_line_output = 3 +line_length = 88 + +[linters.mypy] +command = "mypy" +python_version = "3.11" +strict = true +ignore_missing_imports = true + +[linters.bandit] +command = "bandit -r src -f json" +exclude_dirs = ["tests", "test"] +output_format = "json" + +[linters.safety] +command = "safety check --json --file safety_report.json" +check_authored = true +check_only = false + +[linters.yamllint] +command = "yamllint" +extends = "default" +rules.line_length.max = 88 +rules.indentation.indent_sequences = true + +[linters.markdownlint] +command = "markdownlint" +config = ".markdownlint.json" +extensions = ["md", "markdown"] + +[linters.pydocstyle] +command = "pydocstyle" +convention = "google" +add_ignore = ["D100", "D104", "D107"] +match = "(?!test_).*\\.py" + +[linters.docformatter] +command = "docformatter --check" +wrap = 88 +blank_after_summary = false + +[linters.interrogate] +command = "interrogate" +fail_under = 80 +ignore_init_method = true +ignore_init_module = false +ignore_magic = false +ignore_nested_functions = false +ignore_private = true +ignore_property_decorators = true +ignore_module = false + +[linters.vulture] +command = "vulture" +min_confidence = 80 +paths = ["src"] +exclude = ["tests/*"] + +[linters.radon] +command = "radon cc -nb -a" +max_complexity = 10 +min_a = 0 +show_closures = false + +[linters.pyupgrade] +command = "pyupgrade --py37-plus --keep-runtime-typing" + +[workflow] +staged_files_command = "git diff --cached --name-only --diff-filter=AM" +staged_py_files_command = 'git diff --cached --name-only --diff-filter=AM -- "*.py" "*.pyi"' +parallel_linters = true +max_parallel_jobs = 4 +timeout = 300 + +[reporting] +format = "github" +fail_on_error = true +show_diff = true +verbose = false diff --git a/packages/phenokit-config-kit/linting/pre-commit-hooks.yaml b/packages/phenokit-config-kit/linting/pre-commit-hooks.yaml new file mode 100644 index 0000000..0dfb78f --- /dev/null +++ b/packages/phenokit-config-kit/linting/pre-commit-hooks.yaml @@ -0,0 +1,134 @@ +# Pre-commit hooks for config-kit +# These hooks run automatically when committing + +repos: + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3.11 + args: [--line-length=88] + files: \.(py|pyi)$ + + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: [--profile=black, --line-length=88] + files: \.(py|pyi)$ + + - repo: https://github.com/pycqa/ruff-pre-commit + rev: v0.6.4 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix] + files: \.(py|pyi)$ + - id: ruff-format + args: [--check] + files: \.(py|pyi)$ + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.13.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: [--ignore-missing-imports, --show-error-codes] + files: \.(py|pyi)$ + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.9 + hooks: + - id: bandit + args: [-r, src, -f, json] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/PyCQA/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: [-d, relaxed] + files: \.(yaml|yml)$ + + - repo: https://github.com/PyCQA/markdownlint-cli + rev: v0.41.0 + hooks: + - id: markdownlint + args: [-c, .markdownlint.json] + files: \.(md|markdown)$ + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + args: [-e, SC1091] + files: \.(sh|bash|zsh)$ + + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + args: [--convention=google] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/pycqa/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + args: [--in-place, --wrap-summaries=88, --wrap-descriptions=88] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/econchick/interrogate + rev: 1.5.0 + hooks: + - id: interrogate + args: [--verbose, --ignore-init-method, --ignore-init-module, --fail-under=80] + files: \.(py|pyi)$ + exclude: ^tests/ + + - repo: https://github.com/jakewharton/pygradle-precommit + rev: 1.0.2 + hooks: + - id: pyupgrade + args: [--py37-plus, --keep-runtime-typing] + files: \.(py|pyi)$ + + - repo: local + hooks: + - id: safety-check + name: Safety check + entry: safety + language: system + args: [check, --json, --file, safety_report.json] + pass_filenames: false + always_run: true + + - id: shellcheck-fix + name: Shellcheck (fix) + entry: shellcheck + language: system + args: [-e, SC1091, -f, gcc] + files: \.(sh|bash|zsh)$ + + - id: yamllint-fix + name: YAMLLint (fix) + entry: yamllint + language: system + args: [-d, relaxed, -f] + files: \.(yaml|yml)$ + + - id: pre-commit-hooks + name: Pre-commit hooks + entry: bash + language: system + args: [linting/git-hooks/pre-commit] + files: \.(py|pyi|yaml|yml|md|sh)$ + + - id: commit-message-check + name: Commit message check + entry: bash + language: system + args: [linting/git-hooks/commit-msg] + files: ^COMMIT_EDITMSG$^ diff --git a/packages/phenokit-config-kit/linting/pyproject.toml b/packages/phenokit-config-kit/linting/pyproject.toml new file mode 100644 index 0000000..de171fe --- /dev/null +++ b/packages/phenokit-config-kit/linting/pyproject.toml @@ -0,0 +1,195 @@ +[tool.ruff] +line-length = 88 +target-version = "py311" +fix = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG001", # unused arguments in functions + "ARG002", # unused arguments in methods + "ARG005", # unused arguments in lambda functions +] + +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults + "W191", # indentation contains tabs + "B904", # Allow raising exceptions without from e, for HTTPException +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG001", "ARG002"] +"__init__.py" = ["F401"] + +[tool.ruff.lint.pyupgrade] +keep-runtime-typing = true + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["config_kit"] +force_single_line = false +combine_as_imports = true +include_trailing_comma = true + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist +)/ +''' + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true +plugins = ["pydantic.mypy"] + +[[tool.mypy.overrides]] +module = [ + "pytest.*", + "pytest_asyncio.*", + "bandit.*", + "safety.*", + "shellcheck.*", + "yamllint.*", + "markdownlint.*", + "lychee.*", + "pre_commit.*", + "vulture.*", + "radon.*", + "pyupgrade.*", + "docformatter.*", + "pydocstyle.*", + "interrogate.*", + "detect_secrets.*", + "semgrep.*", + "trivy.*", + "mkdocs.*", + "allure.*", + "locust.*", + "memory_profiler.*", + "playwright.*", + "lychee.*", +] +ignore_missing_imports = true + +[tool.bandit] +exclude_dirs = ["tests", "test"] +skips = ["B101"] # assert_used + +[tool.safety] +check-authored = true +check-only = false +json-report = true +output = "safety_report.json" + +[tool.yamllint] +extends = "default" + +[tool.yamllint.rules] +line-length = { max = 88, level = "warning" } +indentation = { indent-sequences = true, check-multi-line-strings = false } + +[tool.markdownlint] +MD001 = false +MD013 = {line_length = 88} +MD024 = {allow_different_nesting = true} +MD033 = false +MD034 = false +MD036 = false +MD037 = false +MD038 = false +MD039 = false +MD040 = false +MD041 = false +MD042 = false +MD043 = false +MD044 = false +MD045 = false +MD046 = false +MD047 = false +MD048 = {level = "warning"} +MD049 = {level = "warning"} +MD050 = {level = "warning"} +MD051 = {level = "warning"} +MD052 = {level = "warning"} +MD053 = {level = "warning"} +MD054 = {level = "warning"} + +[tool.pydocstyle] +convention = "google" +add-ignore = ["D100", "D104", "D107"] +match = "(?!test_).*\\.py" +match-dir = "(?!tests).*" + +[tool.interrogate] +ignore-init-method = true +ignore-init-module = false +ignore-magic = false +ignore-nested-functions = false +ignore-private = true +ignore-property-decorators = true +ignore-module = false +fail-under = 80 +exclude = ["tests", "test"] +verbose = 1 +quiet = false +whitelist-names = [] +whitelist-regex = [] +color = true +omit-covered-files = false + +[tool.pyupgrade] +keep-runtime-typing = true + +[tool.docformatter] +wrap-summaries = 88 +wrap-descriptions = 88 +blank-after-summary = false +pre-summary-newline = false +make-summary-multi-line = false +close-quotes-on-newline = false + +[tool.radon] +cc-max = 10 +max-complexity = 10 +min-a = 0 +show-closures = false +exclude = ["tests/*"] + +[tool.vulture] +min_confidence = 80 +verbose = false +make_whitelist = false +paths = ["src"] +exclude = ["tests/*"] diff --git a/packages/phenokit-config-kit/pre-commit/basic.yaml b/packages/phenokit-config-kit/pre-commit/basic.yaml new file mode 100644 index 0000000..eddb63d --- /dev/null +++ b/packages/phenokit-config-kit/pre-commit/basic.yaml @@ -0,0 +1,32 @@ +# Basic Pre-commit Configuration +# Minimal configuration for simple projects + +repos: + # Pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=51200'] # 50MB limit + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-json + - id: check-toml + + # Ruff linting and formatting (replaces black + isort + flake8) + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + - id: ruff-format + + # MyPy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: ['--ignore-missing-imports'] diff --git a/packages/phenokit-config-kit/pre-commit/comprehensive.yaml b/packages/phenokit-config-kit/pre-commit/comprehensive.yaml new file mode 100644 index 0000000..9420ef7 --- /dev/null +++ b/packages/phenokit-config-kit/pre-commit/comprehensive.yaml @@ -0,0 +1,292 @@ +# Comprehensive Pre-commit Configuration +# Restored from atoms_mcp-old massive simplification +# This is the FULL configuration that was lost + +repos: + # Pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=51200'] # 50MB limit + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-xml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-docstring-first + - id: check-ast + - id: check-merge-conflict + - id: debug-statements + - id: check-json + - id: check-yaml + - id: check-toml + - id: check-xml + - id: check-merge-conflict + - id: debug-statements + - id: name-tests-test + - id: requirements-txt-fixer + - id: fix-byte-order-marker + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + - id: check-added-large-files + - id: check-merge-conflict + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-xml + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-docstring-first + - id: check-ast + - id: check-merge-conflict + - id: debug-statements + - id: check-json + - id: check-yaml + - id: check-toml + - id: check-xml + - id: check-merge-conflict + - id: debug-statements + - id: name-tests-test + - id: requirements-txt-fixer + - id: fix-byte-order-marker + - id: check-case-conflict + - id: check-merge-conflict + - id: check-symlinks + + # Black code formatting + - repo: https://github.com/psf/black + rev: 24.10.0 + hooks: + - id: black + language_version: python3 + args: ['--line-length=100', '--target-version=py311'] + + # isort import sorting + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + args: ['--profile', 'black', '--line-length=100'] + + # MyPy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: ['--strict', '--ignore-missing-imports'] + + # Ruff linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + - id: ruff-format + + # Bandit security scanning + - repo: https://github.com/pycqa/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ['-r', '.', '-f', 'json', '-o', 'bandit-report.json'] + exclude: 'tests/' + + # Prospector comprehensive analysis + - repo: https://github.com/PyCQA/prospector + rev: 1.10.3 + hooks: + - id: prospector + args: ['--strictness=veryhigh', '--doc-warnings'] + + # Radon complexity analysis + - repo: https://github.com/rubik/radon + rev: 6.0.1 + hooks: + - id: radon-cc + args: ['--min=B', '--show-complexity'] + - id: radon-mi + args: ['--min=B'] + + # Vulture dead code detection + - repo: https://github.com/jendrikseipp/vulture + rev: v2.11 + hooks: + - id: vulture + args: ['--min-confidence=80'] + + # Pyupgrade Python syntax upgrader + - repo: https://github.com/asottile/pyupgrade + rev: v3.15.2 + hooks: + - id: pyupgrade + args: ['--py311-plus'] + + # Docformatter docstring formatting + - repo: https://github.com/pycqa/docformatter + rev: v1.7.5 + hooks: + - id: docformatter + args: ['--wrap-summaries=100', '--wrap-descriptions=100'] + + # Pydocstyle docstring style checking + - repo: https://github.com/pycqa/pydocstyle + rev: 6.3.0 + hooks: + - id: pydocstyle + args: ['--convention=google'] + + # Interrogate documentation coverage + - repo: https://github.com/econchick/interrogate + rev: 1.5.0 + hooks: + - id: interrogate + args: ['--fail-under=80', '--ignore-init-module', '--ignore-init-method'] + + # Pip-audit dependency security audit + - repo: https://github.com/pypa/pip-audit + rev: v2.7.0 + hooks: + - id: pip-audit + args: ['--desc', '--format=json', '--output=pip-audit-report.json'] + + # Safety dependency vulnerability scanning + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: v1.3.2 + hooks: + - id: python-safety-dependencies-check + + # Markdown linting + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.38.0 + hooks: + - id: markdownlint + args: ['--fix'] + + # YAML linting + - repo: https://github.com/adrienverge/yamllint + rev: v1.35.1 + hooks: + - id: yamllint + args: ['-d', 'relaxed'] + + # Shell script linting + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck + + # Dockerfile linting + - repo: https://github.com/hadolint/hadolint + rev: v2.12.0 + hooks: + - id: hadolint-docker + args: ['--ignore', 'DL3008', '--ignore', 'DL3009'] + + # File cleanup hooks + - repo: local + hooks: + - id: remove-tabs + name: Remove tabs + entry: sed + args: ['-i', 's/\t/ /g'] + language: system + files: \.py$ + + - id: remove-trailing-spaces + name: Remove trailing spaces + entry: sed + args: ['-i', 's/[[:space:]]*$//'] + language: system + files: \.(py|md|yaml|yml|json|toml)$ + + - id: fix-line-endings + name: Fix line endings + entry: dos2unix + language: system + files: \.(py|md|yaml|yml|json|toml|sh)$ + + - id: remove-empty-lines + name: Remove empty lines at end of file + entry: sed + args: ['-i', '-e', ':a', '-e', '/^\s*$/N;ba', '-e', 's/\n*$//'] + language: system + files: \.(py|md|yaml|yml|json|toml)$ + + # Architecture fitness hooks (custom) + - repo: local + hooks: + - id: architecture-fitness + name: Architecture fitness check + entry: python + args: ['scripts/architecture_fitness_check.py'] + language: system + files: \.py$ + pass_filenames: false + + - id: file-size-check + name: File size check + entry: python + args: ['scripts/file_size_check.py', '--max-lines=500'] + language: system + files: \.py$ + + - id: import-boundary-check + name: Import boundary check + entry: python + args: ['scripts/import_boundary_check.py'] + language: system + files: \.py$ + + - id: dependency-direction-check + name: Dependency direction check + entry: python + args: ['scripts/dependency_direction_check.py'] + language: system + files: \.py$ + + - id: naming-convention-check + name: Naming convention check + entry: python + args: ['scripts/naming_convention_check.py'] + language: system + files: \.py$ + + - id: cyclomatic-complexity-check + name: Cyclomatic complexity check + entry: python + args: ['scripts/cyclomatic_complexity_check.py', '--max-complexity=10'] + language: system + files: \.py$ + + - id: test-coverage-check + name: Test coverage check + entry: python + args: ['scripts/test_coverage_check.py', '--min-coverage=80'] + language: system + files: \.py$ + + - id: config-validation-check + name: Config validation check + entry: python + args: ['scripts/config_validation_check.py'] + language: system + files: \.(yaml|yml|json|toml)$ + + - id: dependency-security-audit + name: Dependency security audit + entry: python + args: ['scripts/dependency_security_audit.py'] + language: system + files: requirements.*\.txt$ + + - id: comprehensive-report + name: Comprehensive quality report + entry: python + args: ['scripts/comprehensive_quality_report.py'] + language: system + pass_filenames: false diff --git a/packages/phenokit-config-kit/pre-commit/security.yaml b/packages/phenokit-config-kit/pre-commit/security.yaml new file mode 100644 index 0000000..86a8813 --- /dev/null +++ b/packages/phenokit-config-kit/pre-commit/security.yaml @@ -0,0 +1,114 @@ +# Security-focused Pre-commit Configuration +# Enhanced security scanning and validation + +repos: + # Pre-commit hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: check-added-large-files + args: ['--maxkb=51200'] # 50MB limit + - id: check-merge-conflict + - id: end-of-file-fixer + - id: trailing-whitespace + - id: check-yaml + - id: check-json + - id: check-toml + + # Ruff linting and formatting + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.3 + hooks: + - id: ruff + args: ['--fix', '--exit-non-zero-on-fix'] + - id: ruff-format + + # MyPy type checking + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.11.2 + hooks: + - id: mypy + additional_dependencies: [types-all] + args: ['--strict', '--ignore-missing-imports'] + + # Bandit security scanning + - repo: https://github.com/pycqa/bandit + rev: 1.7.8 + hooks: + - id: bandit + args: ['-r', '.', '-f', 'json', '-o', 'bandit-report.json'] + exclude: 'tests/' + + # Safety dependency vulnerability scanning + - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + rev: v1.3.2 + hooks: + - id: python-safety-dependencies-check + + # Pip-audit dependency security audit + - repo: https://github.com/pypa/pip-audit + rev: v2.7.0 + hooks: + - id: pip-audit + args: ['--desc', '--format=json', '--output=pip-audit-report.json'] + + # Detect secrets + - repo: https://github.com/Yelp/detect-secrets + rev: v1.4.0 + hooks: + - id: detect-secrets + args: ['--baseline', '.secrets.baseline'] + + # Semgrep SAST + - repo: https://github.com/returntocorp/semgrep + rev: v1.62.0 + hooks: + - id: semgrep + args: ['--config=auto', '--severity=ERROR', '--severity=WARNING'] + + # Trivy container vulnerability scanner + - repo: https://github.com/aquasecurity/trivy + rev: v0.45.0 + hooks: + - id: trivy + args: ['fs', '--security-checks', 'vuln,secret', '--format', 'json', '--output', 'trivy-report.json'] + + # Presidio PII detection + - repo: local + hooks: + - id: presidio-analyze + name: Presidio PII analysis + entry: python + args: ['scripts/presidio_analyze.py'] + language: system + files: \.(py|md|txt)$ + + # Security headers check + - repo: local + hooks: + - id: security-headers-check + name: Security headers check + entry: python + args: ['scripts/security_headers_check.py'] + language: system + files: \.(py|yaml|yml)$ + + # Dependency license check + - repo: local + hooks: + - id: license-check + name: License compliance check + entry: python + args: ['scripts/license_check.py'] + language: system + files: requirements.*\.txt$ + + # SBOM generation + - repo: local + hooks: + - id: sbom-generation + name: SBOM generation + entry: python + args: ['scripts/sbom_generation.py'] + language: system + files: requirements.*\.txt$ diff --git a/packages/phenokit-config-kit/pyproject.toml b/packages/phenokit-config-kit/pyproject.toml new file mode 100644 index 0000000..0c7b4b2 --- /dev/null +++ b/packages/phenokit-config-kit/pyproject.toml @@ -0,0 +1,155 @@ +[build-system] +requires = ["hatchling", "hatch-vcs"] +build-backend = "hatchling.build" + +[project] +name = "phenokit-config-kit" +version = "0.1.0" +description = "Centralized configuration management for Python projects - pytest plugins, fixtures, linting configs, and pre-commit hooks" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "PhenoKit Team" }] +keywords = ["pytest", "configuration", "linting", "pre-commit", "testing", "fixtures"] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development :: Quality Assurance", + "Topic :: Software Development :: Testing", +] +requires-python = ">=3.11" + +[project.optional-dependencies] +# Core dependencies (required for basic usage) +core = [ + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "pyyaml>=6.0", + "toml>=0.10.0", + "click>=8.1.0", + "rich>=13.0.0", +] + +# Pre-commit dependencies +precommit = [ + "pre-commit>=3.0.0", +] + +# Testing dependencies - includes pytest plugins +testing = [ + "pytest>=7.4.0", + "pytest-asyncio>=0.21.0", + "pytest-cov>=4.1.0", + "pytest-mock>=3.11.0", + "pytest-xdist>=3.3.0", + "coverage>=7.4.0", + "psutil>=5.9.0", +] + +# Linting dependencies +linting = [ + "ruff>=0.1.0", + "black>=23.0.0", + "isort>=5.12.0", + "mypy>=1.5.0", + "bandit>=1.7.5", + "prospector>=1.10.0", + "yamllint>=1.35.0", + "markdownlint-cli>=0.38.0", +] + +# All dependencies +all = [ + "phenokit-config-kit[core,precommit,testing,linting]" +] + +[project.entry-points.pytest11] +config_kit_architecture = "config_kit.pytest.plugins.architecture" +config_kit_performance = "config_kit.pytest.plugins.performance" +config_kit_security = "config_kit.pytest.plugins.security" + +[project.urls] +Homepage = "https://github.com/KooshaPari/phenotype-sdk/tree/main/lang/python/packages/phenokit-config-kit" +Documentation = "https://github.com/KooshaPari/phenotype-sdk/blob/main/lang/python/packages/phenokit-config-kit/README.md" +Repository = "https://github.com/KooshaPari/phenotype-sdk" + +[tool.hatch.build.targets.wheel] +packages = ["config_kit"] + +[tool.setuptools.packages.find] +where = ["."] +include = ["config_kit*"] + +[tool.setuptools.package-data] +config_kit = [ + "pre-commit/*.yaml", + "pytest/*.ini", + "pytest/plugins/*.py", + "pytest/fixtures/*.py", + "pytest/data/*.py", + "linting/*", + "linting/ci-cd/*", + "linting/git-hooks/*", +] + +[tool.ruff] +line-length = 88 +target-version = "py311" +fix = true + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "I", # isort + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade +] +ignore = [ + "E501", # line too long, handled by black + "B008", # do not perform function calls in argument defaults +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["ARG001", "ARG002"] +"__init__.py" = ["F401"] + +[tool.black] +line-length = 88 +target-version = ['py311'] +include = '\.pyi?$' + +[tool.isort] +profile = "black" +multi_line_output = 3 +line_length = 88 +known_first_party = ["config_kit"] +combine_as_imports = true +include_trailing_comma = true + +[tool.mypy] +python_version = "3.11" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +ignore_missing_imports = true +plugins = ["pydantic.mypy"] + +[tool.pytest.ini_options] +minversion = "7.4" +testpaths = ["tests"] +python_files = "test_*.py" +python_classes = "Test*" +python_functions = "test_*" +markers = [ + "unit: Unit tests (fast, isolated)", + "integration: Integration tests", + "performance: Performance tests", + "security: Security tests", +] diff --git a/packages/phenokit-config-kit/pytest/README.md b/packages/phenokit-config-kit/pytest/README.md new file mode 100644 index 0000000..5e2aa1a --- /dev/null +++ b/packages/phenokit-config-kit/pytest/README.md @@ -0,0 +1,605 @@ +# Pytest Configuration Kit + +This package provides comprehensive pytest configurations, plugins, fixtures, and test data management utilities for Python projects. + +## Features + +- **Multiple Configuration Profiles**: Basic, comprehensive, CI, parallel, performance, and security configurations +- **Architecture Testing**: File size validation, import boundary enforcement, dependency direction validation +- **Performance Testing**: Benchmark testing, memory profiling, performance regression detection +- **Security Testing**: Vulnerability scanning, authentication testing, authorization testing +- **Rich Fixtures**: Common, performance, and security fixtures for testing +- **Test Data Management**: Factory for generating test data across different entity types + +## Quick Start + +### Basic Usage + +```python +# Use basic configuration +pytest --ini=pytest/basic.ini + +# Use comprehensive configuration +pytest --ini=pytest/comprehensive.ini + +# Use CI configuration +pytest --ini=pytest/ci.ini +``` + +### Advanced Usage + +```python +# Use parallel execution +pytest --ini=pytest/parallel.ini -m "fast and not serial" -n auto + +# Use performance testing +pytest --ini=pytest/performance.ini -m performance + +# Use security testing +pytest --ini=pytest/security.ini -m security +``` + +## Configuration Files + +### Basic Configuration (`basic.ini`) + +Simple configuration for basic projects: + +```ini +[pytest] +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + +markers = + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + slow: Slow running tests (> 5 seconds) + fast: Fast running tests (< 1 second) +``` + +### Comprehensive Configuration (`comprehensive.ini`) + +Enterprise-grade configuration with parallel execution, coverage, and extensive markers: + +```ini +[pytest] +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +addopts = + -v + --tb=short + --strict-markers + --strict-config + --maxfail=10 + --durations=10 + --durations-min=2.0 + --cache-clear + # Parallel execution + -n auto + --dist=loadscope + # Coverage analysis + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-fail-under=80 + --cov-branch + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml +``` + +### CI Configuration (`ci.ini`) + +Optimized for CI/CD environments with parallel execution and comprehensive reporting: + +```ini +[pytest] +# CI/CD-optimized options +addopts = + -ra + --strict-markers + --strict-config + --tb=short + --maxfail=10 + --durations=10 + --durations-min=1.0 + --cache-clear + # Parallel execution optimized for CI + -n auto + --dist=worksteal + # Include integration tests but exclude external service dependencies + -m "not external and not nats and not redis and not temporal" + # Coverage reporting + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-fail-under=85 + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml +``` + +### Parallel Configuration (`parallel.ini`) + +Optimized for parallel test execution: + +```ini +[pytest] +# Fast unit tests only (no external dependencies) +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + --strict-config + --maxfail=3 + --durations=5 + --durations-min=1.0 + -n auto + --dist=loadscope +``` + +### Performance Configuration (`performance.ini`) + +Specialized for performance testing: + +```ini +[pytest] +# Performance testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=20 + --durations-min=0.1 + # Performance-specific options + --benchmark-only + --benchmark-sort=mean + --benchmark-skip + --benchmark-save=performance_results + --benchmark-save-data + # Memory profiling + --profile + --profile-svg + # Coverage for performance tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov +``` + +### Security Configuration (`security.ini`) + +Specialized for security testing: + +```ini +[pytest] +# Security testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + --durations-min=1.0 + # Security-specific options + --bandit + --bandit-config=.bandit + --safety + --safety-json + # Coverage for security tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml +``` + +## Plugins + +### Architecture Plugin + +Provides architecture fitness tests: + +```python +# Test import boundaries +def test_import_boundaries(): + """Test that import boundaries are respected.""" + pass + +# Test dependency direction +def test_dependency_direction(): + """Test that dependency direction is respected.""" + pass + +# Test file sizes +def test_file_sizes(): + """Test that file sizes are within limits.""" + pass + +# Test cyclomatic complexity +def test_cyclomatic_complexity(): + """Test that cyclomatic complexity is within limits.""" + pass +``` + +### Performance Plugin + +Provides performance testing capabilities: + +```python +# Monitor performance during test execution +@pytest.fixture +def performance_monitor(): + """Monitor performance during test execution.""" + pass + +# Benchmark testing +@pytest.mark.performance +def test_benchmark_performance(): + """Test that benchmark performance meets thresholds.""" + pass + +# Memory usage testing +@pytest.mark.memory +def test_memory_usage(): + """Test that memory usage is within thresholds.""" + pass +``` + +### Security Plugin + +Provides security testing capabilities: + +```python +# Vulnerability scanning +@pytest.mark.security +def test_vulnerability_scan(): + """Scan code for common vulnerabilities.""" + pass + +# Forbidden imports check +@pytest.mark.security +def test_forbidden_imports(): + """Check for forbidden imports that could be security risks.""" + pass + +# Hardcoded secrets check +@pytest.mark.security +def test_hardcoded_secrets(): + """Check for hardcoded secrets in the code.""" + pass +``` + +## Fixtures + +### Common Fixtures + +```python +# Temporary directory +@pytest.fixture +def temp_dir(): + """Create a temporary directory for testing.""" + pass + +# Mock client +@pytest.fixture +def mock_client(): + """Create a mock client for testing.""" + pass + +# Test data +@pytest.fixture +def test_data(): + """Provide test data for testing.""" + pass + +# Performance monitor +@pytest.fixture +def performance_monitor(): + """Monitor performance during test execution.""" + pass + +# Security context +@pytest.fixture +def security_context(): + """Provide security context for testing.""" + pass +``` + +### Performance Fixtures + +```python +# Performance monitor +@pytest.fixture +def performance_monitor(): + """Monitor performance during test execution.""" + pass + +# Benchmark data +@pytest.fixture +def benchmark_data(): + """Provide benchmark data for testing.""" + pass + +# Load test data +@pytest.fixture +def load_test_data(): + """Provide load test data for testing.""" + pass + +# Memory profiler +@pytest.fixture +def memory_profiler(): + """Provide memory profiling capabilities.""" + pass +``` + +### Security Fixtures + +```python +# Security context +@pytest.fixture +def security_context(): + """Provide security context for testing.""" + pass + +# Mock auth client +@pytest.fixture +def mock_auth_client(): + """Create a mock authentication client for testing.""" + pass + +# Test credentials +@pytest.fixture +def test_credentials(): + """Provide test credentials for testing.""" + pass + +# Test tokens +@pytest.fixture +def test_tokens(): + """Provide test tokens for testing.""" + pass +``` + +## Test Data Management + +### Test Data Factory + +```python +from config_kit.pytest.data.factory import TestDataFactory + +# Create factory +factory = TestDataFactory(seed=42) + +# Generate user data +user_data = factory.user_data() +# { +# "id": "uuid-123", +# "username": "testuser", +# "email": "test@example.com", +# "full_name": "Test User", +# "roles": ["user"], +# "created_at": datetime.now(), +# "updated_at": datetime.now(), +# "is_active": True, +# } + +# Generate organization data +org_data = factory.organization_data() +# { +# "id": "uuid-456", +# "name": "Test Organization", +# "description": "Test organization description", +# "created_at": datetime.now(), +# "updated_at": datetime.now(), +# "is_active": True, +# } + +# Generate project data +project_data = factory.project_data(organization_id="org-123") +# { +# "id": "uuid-789", +# "name": "Test Project", +# "description": "Test project description", +# "status": "active", +# "organization_id": "org-123", +# "created_at": datetime.now(), +# "updated_at": datetime.now(), +# } + +# Generate related data +users = factory.create_related_data("user", count=5) +organizations = factory.create_related_data("organization", count=3) +``` + +## Usage Examples + +### Basic Testing + +```python +import pytest +from config_kit.pytest.fixtures.common import mock_client, test_data + +def test_basic_functionality(mock_client, test_data): + """Test basic functionality.""" + result = mock_client.call_tool("test_tool", test_data["user"]) + assert result["success"] is True +``` + +### Performance Testing + +```python +import pytest +from config_kit.pytest.fixtures.performance import performance_monitor + +def test_performance(performance_monitor): + """Test performance.""" + with performance_monitor["measure"]("test_operation"): + # Perform operation + result = expensive_operation() + + assert result is not None + assert performance_monitor["total_duration"] < 1.0 +``` + +### Security Testing + +```python +import pytest +from config_kit.pytest.fixtures.security import security_context, test_credentials + +def test_authentication(security_context, test_credentials): + """Test authentication.""" + auth_result = authenticate_user( + test_credentials["username"], + test_credentials["password"] + ) + + assert auth_result["success"] is True + assert auth_result["user_id"] == security_context["user_id"] +``` + +### Architecture Testing + +```python +import pytest +from config_kit.pytest.plugins.architecture import ArchitecturePlugin + +def test_architecture_fitness(): + """Test architecture fitness.""" + # This test is automatically added by the ArchitecturePlugin + pass +``` + +## Command Line Options + +### Architecture Testing + +```bash +# Set maximum file size +pytest --max-file-size=1000 + +# Set maximum complexity +pytest --max-complexity=10 + +# Enable/disable import enforcement +pytest --enforce-imports +pytest --no-enforce-imports + +# Enable/disable dependency enforcement +pytest --enforce-dependencies +pytest --no-enforce-dependencies +``` + +### Performance Testing + +```bash +# Set benchmark threshold +pytest --benchmark-threshold=1.0 + +# Set memory threshold +pytest --memory-threshold=100 +``` + +## Integration with CI/CD + +### GitHub Actions + +```yaml +name: Tests +on: [push, pull_request] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install dependencies + run: | + pip install -e . + pip install pytest pytest-cov + + - name: Run tests + run: | + pytest --ini=pytest/ci.ini + + - name: Upload coverage + uses: codecov/codecov-action@v3 + with: + file: ./coverage.xml +``` + +### Pre-commit Hooks + +```yaml +repos: + - repo: local + hooks: + - id: pytest + name: pytest + entry: pytest + args: [--ini=pytest/basic.ini, -m "not slow"] + language: system + pass_filenames: false + always_run: true +``` + +## Best Practices + +1. **Use appropriate configuration**: Choose the right configuration file for your needs +2. **Mark tests appropriately**: Use markers to categorize tests (unit, integration, performance, security) +3. **Use fixtures**: Leverage the provided fixtures for common testing patterns +4. **Generate test data**: Use the TestDataFactory for consistent test data generation +5. **Monitor performance**: Use performance fixtures to monitor test execution +6. **Test security**: Use security fixtures and plugins to test security aspects +7. **Validate architecture**: Use architecture plugins to validate architectural constraints + +## Troubleshooting + +### Common Issues + +1. **Import errors**: Ensure the config-kit package is installed and in the Python path +2. **Plugin not found**: Check that the plugin is properly registered in pytest configuration +3. **Fixture not found**: Ensure the fixture is imported or the module is in the Python path +4. **Performance issues**: Use parallel execution and appropriate markers for test categorization + +### Debug Mode + +```bash +# Enable debug mode +pytest --ini=pytest/comprehensive.ini -v -s --tb=long + +# Enable logging +pytest --ini=pytest/comprehensive.ini --log-cli-level=DEBUG +``` + +## Contributing + +1. Add new configurations in the `pytest/` directory +2. Add new plugins in the `pytest/plugins/` directory +3. Add new fixtures in the `pytest/fixtures/` directory +4. Add new test data utilities in the `pytest/data/` directory +5. Update documentation and examples + +## License + +MIT License - See LICENSE file for details. \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/basic.ini b/packages/phenokit-config-kit/pytest/basic.ini new file mode 100644 index 0000000..dd870b0 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/basic.ini @@ -0,0 +1,27 @@ +[pytest] +# Basic pytest configuration for simple projects +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Basic options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + +# Basic markers +markers = + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + slow: Slow running tests (> 5 seconds) + fast: Fast running tests (< 1 second) + +# Basic warnings +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/ci.ini b/packages/phenokit-config-kit/pytest/ci.ini new file mode 100644 index 0000000..526cb8c --- /dev/null +++ b/packages/phenokit-config-kit/pytest/ci.ini @@ -0,0 +1,88 @@ +[pytest] +# CI/CD Profile - Comprehensive validation with optimized parallel execution +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# CI/CD-optimized options +addopts = + -ra + --strict-markers + --strict-config + --tb=short + --maxfail=10 + --durations=10 + --durations-min=1.0 + --cache-clear + # Parallel execution optimized for CI + -n auto + --dist=worksteal + # Include integration tests but exclude external service dependencies + -m "not external and not nats and not redis and not temporal" + # Coverage reporting + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-fail-under=85 + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# CI-specific markers +markers = + fast: Fast unit tests (< 1 second execution) + slow: Slow integration tests (> 5 seconds execution) + critical: Critical path tests for PR validation + external: Tests requiring external services (skip in CI) + unit: Pure unit tests with mocks + integration: Integration tests with real services + benchmark: Performance benchmark tests + tool_specific: Tests specific to individual tools + provider_specific: Tests specific to AI model providers + workflow: Tests for multi-step workflows + security: Security-related tests + performance: Performance testing and optimization + memory: Memory usage and leak tests + concurrency: Tests for concurrent operations + error_handling: Tests focused on error scenarios + edge_case: Tests for boundary conditions + flaky: Tests that may be flaky and need retry + nats: Tests requiring NATS messaging + redis: Tests requiring Redis connection + temporal: Tests requiring Temporal workflow + oauth: Tests for OAuth2/authentication flows + http: Tests for HTTP server functionality + websocket: Tests for WebSocket communication + mcp: Tests for MCP protocol compliance + +# CI-friendly warnings (stricter than dev) +filterwarnings = + error + ignore::UserWarning + ignore::DeprecationWarning:websockets.* + ignore::DeprecationWarning:pydantic.* + ignore::PendingDeprecationWarning + ignore:.*unclosed.*:ResourceWarning + ignore:.*coroutine.*never awaited:RuntimeWarning + ignore:.*Task was destroyed.*:RuntimeWarning + +# CI timeout settings +timeout = 300 +timeout_method = "thread" + +# CI logging settings +log_cli = false +log_cli_level = WARNING +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" + +# JUnit XML configuration +junit_family = "xunit2" +junit_logging = "system-out" +junit_log_passing_tests = false \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/comprehensive.ini b/packages/phenokit-config-kit/pytest/comprehensive.ini new file mode 100644 index 0000000..621ad6c --- /dev/null +++ b/packages/phenokit-config-kit/pytest/comprehensive.ini @@ -0,0 +1,140 @@ +[pytest] +# Comprehensive pytest configuration for enterprise projects +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Comprehensive options +addopts = + -v + --tb=short + --strict-markers + --strict-config + --maxfail=10 + --durations=10 + --durations-min=2.0 + --cache-clear + # Parallel execution + -n auto + --dist=loadscope + # Coverage analysis + --cov=src + --cov-report=term-missing:skip-covered + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + --cov-report=json:coverage.json + --cov-fail-under=80 + --cov-branch + # JUnit XML for CI integration + --junitxml=reports/pytest-junit.xml + +# Comprehensive markers +markers = + # Test types + unit: Unit tests (fast, isolated, no I/O, <1s per test) + integration: Integration tests (slower, may use I/O, require services) + e2e: End-to-end tests (slowest, full system workflows) + smoke: Smoke tests for quick validation + regression: Comprehensive regression coverage + + # Performance markers + fast: Fast running tests (< 1 second) + slow: Slow running tests (> 5 seconds) + performance: Performance and benchmarking tests + benchmark: Performance benchmark tests + memory: Memory usage and leak tests + concurrency: Tests for concurrent operations + + # Architecture markers + domain: Domain layer tests (business logic) + application: Application layer tests (use cases) + adapters: Adapter layer tests (external interfaces) + infrastructure: Infrastructure layer tests + + # Execution markers + parallel: Tests that can run in parallel + serial: Tests that must run serially + critical: Critical path tests for PR validation + flaky: Tests that may be flaky and need retry + + # Service markers + external: Tests requiring external services (skip in CI) + database: Database interaction tests + cache: Cache tests + message_broker: Message broker tests + http: HTTP server functionality tests + websocket: WebSocket communication tests + api: HTTP API level tests + + # Security and compliance + security: Security-related tests + auth: Authentication and authorization checks + rls: Tests that validate RLS policies + compliance: Compliance and regulatory tests + + # Quality markers + error_handling: Tests focused on error scenarios + edge_case: Tests for boundary conditions + timeout: Sets timeout for test execution + contract: Contract validation for external interfaces + + # Workflow markers + workflow: Tests for multi-step workflows + migration: Schema/config migration validation + compatibility: Backwards/forwards compatibility coverage + + # Provider-specific markers + tool_specific: Tests specific to individual tools + provider_specific: Tests specific to AI model providers + oauth: Tests for OAuth2/authentication flows + mcp: Tests for MCP protocol compliance + + # Test execution order + order: Test execution order (pytest-ordering plugin) + dependency: Test dependencies (pytest-dependency plugin) + + # Environment markers + ci: Tests that should run in CI + local: Tests that should run locally + dev: Development environment tests + prod: Production environment tests + + # Skip conditions + skip_if_no_oauth: Skip if OAuth not available + skip_if_no_redis: Skip if Redis not available + skip_if_no_nats: Skip if NATS not available + skip_if_no_temporal: Skip if Temporal not available + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Comprehensive warnings +filterwarnings = + error + ignore::UserWarning + ignore::DeprecationWarning:websockets.* + ignore::DeprecationWarning:pydantic.* + ignore::PendingDeprecationWarning + ignore:.*unclosed.*:ResourceWarning + ignore:.*coroutine.*never awaited:RuntimeWarning + ignore:.*Task was destroyed.*:RuntimeWarning + ignore::pytest.PytestAssertRewriteWarning + ignore:cannot collect test class .* because it has a __init__ constructor:pytest.PytestCollectionWarning + +# Timeout settings +timeout = 300 +timeout_method = "thread" + +# Logging settings +log_cli = true +log_cli_level = INFO +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" + +# JUnit XML configuration +junit_family = "xunit2" +junit_logging = "system-out" +junit_log_passing_tests = false \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/data/__init__.py b/packages/phenokit-config-kit/pytest/data/__init__.py new file mode 100644 index 0000000..4269819 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/data/__init__.py @@ -0,0 +1,13 @@ +""" +Test data management for config-kit package. + +This module provides utilities for managing test data across different projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "TestDataFactory", + "TestDataGenerator", + "TestDataManager", + "TestDataValidator", +] diff --git a/packages/phenokit-config-kit/pytest/data/factory.py b/packages/phenokit-config-kit/pytest/data/factory.py new file mode 100644 index 0000000..10944f7 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/data/factory.py @@ -0,0 +1,225 @@ +""" +Test data factory for generating test data. + +This module provides a factory for generating test data across different projects. +""" + +import random +import string +import uuid +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + + +@dataclass +class TestDataFactory: + """Factory for generating test data.""" + + seed: int | None = None + _random: random.Random = field(default_factory=random.Random) + + def __post_init__(self): + if self.seed is not None: + self._random.seed(self.seed) + + def random_string(self, length: int = 10, chars: str = None) -> str: + """Generate a random string.""" + if chars is None: + chars = string.ascii_letters + string.digits + return "".join(self._random.choices(chars, k=length)) + + def random_email(self, domain: str = "example.com") -> str: + """Generate a random email address.""" + username = self.random_string(8, string.ascii_lowercase + string.digits) + return f"{username}@{domain}" + + def random_uuid(self) -> str: + """Generate a random UUID.""" + return str(uuid.uuid4()) + + def random_int(self, min_val: int = 0, max_val: int = 100) -> int: + """Generate a random integer.""" + return self._random.randint(min_val, max_val) + + def random_float(self, min_val: float = 0.0, max_val: float = 1.0) -> float: + """Generate a random float.""" + return self._random.uniform(min_val, max_val) + + def random_choice(self, choices: list[Any]) -> Any: + """Choose a random item from a list.""" + return self._random.choice(choices) + + def random_choices(self, choices: list[Any], k: int = 1) -> list[Any]: + """Choose multiple random items from a list.""" + return self._random.choices(choices, k=k) + + def random_bool(self) -> bool: + """Generate a random boolean.""" + return self._random.choice([True, False]) + + def random_datetime(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random datetime.""" + if start is None: + start = datetime.now() - timedelta(days=365) + if end is None: + end = datetime.now() + + delta = end - start + random_seconds = self._random.randint(0, int(delta.total_seconds())) + return start + timedelta(seconds=random_seconds) + + def random_date(self, start: datetime = None, end: datetime = None) -> datetime: + """Generate a random date (time set to 00:00:00).""" + dt = self.random_datetime(start, end) + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + def user_data(self, **overrides) -> dict[str, Any]: + """Generate user test data.""" + data = { + "id": self.random_uuid(), + "username": self.random_string(8, string.ascii_lowercase + string.digits), + "email": self.random_email(), + "full_name": f"{self.random_string(6)} {self.random_string(8)}", + "roles": self.random_choices(["user", "admin", "moderator"], k=self.random_int(1, 3)), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def organization_data(self, **overrides) -> dict[str, Any]: + """Generate organization test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Organization {self.random_string(6)}", + "description": f"Test organization description {self.random_string(20)}", + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + "is_active": self.random_bool(), + } + data.update(overrides) + return data + + def project_data(self, organization_id: str = None, **overrides) -> dict[str, Any]: + """Generate project test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Project {self.random_string(6)}", + "description": f"Test project description {self.random_string(20)}", + "status": self.random_choice(["active", "inactive", "archived"]), + "organization_id": organization_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def document_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate document test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Document {self.random_string(6)}", + "content": f"Test document content {self.random_string(50)}", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def requirement_data(self, document_id: str = None, **overrides) -> dict[str, Any]: + """Generate requirement test data.""" + data = { + "id": self.random_uuid(), + "title": f"Test Requirement {self.random_string(6)}", + "description": f"Test requirement description {self.random_string(30)}", + "priority": self.random_choice(["low", "medium", "high", "critical"]), + "status": self.random_choice(["draft", "review", "approved", "rejected"]), + "document_id": document_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def test_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate test entity data.""" + data = { + "id": self.random_uuid(), + "name": f"Test {self.random_string(6)}", + "description": f"Test description {self.random_string(20)}", + "status": self.random_choice(["pending", "running", "passed", "failed", "skipped"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def relationship_data(self, source_id: str = None, target_id: str = None, **overrides) -> dict[str, Any]: + """Generate relationship test data.""" + data = { + "id": self.random_uuid(), + "source_id": source_id or self.random_uuid(), + "target_id": target_id or self.random_uuid(), + "relationship_type": self.random_choice(["parent", "child", "sibling", "related"]), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def workflow_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate workflow test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Workflow {self.random_string(6)}", + "description": f"Test workflow description {self.random_string(20)}", + "status": self.random_choice(["draft", "active", "paused", "completed"]), + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def query_data(self, project_id: str = None, **overrides) -> dict[str, Any]: + """Generate query test data.""" + data = { + "id": self.random_uuid(), + "name": f"Test Query {self.random_string(6)}", + "query": f"SELECT * FROM test_table WHERE id = '{self.random_uuid()}'", + "project_id": project_id or self.random_uuid(), + "created_at": self.random_datetime(), + "updated_at": self.random_datetime(), + } + data.update(overrides) + return data + + def create_related_data(self, entity_type: str, count: int = 5, **overrides) -> list[dict[str, Any]]: + """Create related test data.""" + data = [] + for _ in range(count): + if entity_type == "user": + data.append(self.user_data(**overrides)) + elif entity_type == "organization": + data.append(self.organization_data(**overrides)) + elif entity_type == "project": + data.append(self.project_data(**overrides)) + elif entity_type == "document": + data.append(self.document_data(**overrides)) + elif entity_type == "requirement": + data.append(self.requirement_data(**overrides)) + elif entity_type == "test": + data.append(self.test_data(**overrides)) + elif entity_type == "relationship": + data.append(self.relationship_data(**overrides)) + elif entity_type == "workflow": + data.append(self.workflow_data(**overrides)) + elif entity_type == "query": + data.append(self.query_data(**overrides)) + else: + raise ValueError(f"Unknown entity type: {entity_type}") + return data diff --git a/packages/phenokit-config-kit/pytest/fixtures/__init__.py b/packages/phenokit-config-kit/pytest/fixtures/__init__.py new file mode 100644 index 0000000..fe01c35 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/__init__.py @@ -0,0 +1,18 @@ +""" +Pytest fixtures for config-kit package. + +This module provides a collection of pytest fixtures that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "cache_fixture", + "database_fixture", + "message_broker_fixture", + "mock_client", + "performance_monitor", + "security_context", + "temp_dir", + "test_data", +] diff --git a/packages/phenokit-config-kit/pytest/fixtures/common.py b/packages/phenokit-config-kit/pytest/fixtures/common.py new file mode 100644 index 0000000..598cde9 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/common.py @@ -0,0 +1,226 @@ +""" +Common pytest fixtures for testing. + +This module provides commonly used fixtures across different projects. +""" + +import os +import shutil +import tempfile +from collections.abc import Generator +from pathlib import Path +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def temp_dir() -> Generator[Path, None, None]: + """Create a temporary directory for testing.""" + temp_path = Path(tempfile.mkdtemp()) + try: + yield temp_path + finally: + shutil.rmtree(temp_path, ignore_errors=True) + + +@pytest.fixture +def mock_client() -> Mock: + """Create a mock client for testing.""" + client = Mock() + client.call_tool = Mock(return_value={"success": True, "data": {}}) + client.list_tools = Mock(return_value={"tools": []}) + client.list_resources = Mock(return_value={"resources": []}) + return client + + +@pytest.fixture +def test_data() -> dict[str, Any]: + """Provide test data for testing.""" + return { + "user": { + "id": "test-user-123", + "name": "Test User", + "email": "test@example.com", + "roles": ["user"], + }, + "organization": { + "id": "test-org-123", + "name": "Test Organization", + "description": "Test organization for testing", + }, + "project": { + "id": "test-project-123", + "name": "Test Project", + "status": "active", + "organization_id": "test-org-123", + }, + "document": { + "id": "test-doc-123", + "title": "Test Document", + "content": "Test document content", + "project_id": "test-project-123", + }, + } + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + import time + + import psutil + + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + } + + def measure(name: str): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + monitor_data["measure"] = measure + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def security_context() -> dict[str, Any]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + } + + +@pytest.fixture +def mock_http_client() -> Mock: + """Create a mock HTTP client for testing.""" + client = Mock() + client.get = Mock(return_value=Mock(status_code=200, json=dict)) + client.post = Mock(return_value=Mock(status_code=201, json=dict)) + client.put = Mock(return_value=Mock(status_code=200, json=dict)) + client.delete = Mock(return_value=Mock(status_code=204, json=dict)) + return client + + +@pytest.fixture +def mock_database() -> Mock: + """Create a mock database for testing.""" + db = Mock() + db.execute = Mock(return_value=Mock(rowcount=1)) + db.fetchone = Mock(return_value={}) + db.fetchall = Mock(return_value=[]) + db.commit = Mock() + db.rollback = Mock() + return db + + +@pytest.fixture +def mock_cache() -> Mock: + """Create a mock cache for testing.""" + cache = Mock() + cache.get = Mock(return_value=None) + cache.set = Mock(return_value=True) + cache.delete = Mock(return_value=True) + cache.clear = Mock(return_value=True) + return cache + + +@pytest.fixture +def mock_message_broker() -> Mock: + """Create a mock message broker for testing.""" + broker = Mock() + broker.publish = Mock(return_value=True) + broker.subscribe = Mock(return_value=Mock()) + broker.unsubscribe = Mock(return_value=True) + return broker + + +@pytest.fixture +def mock_file_system() -> Mock: + """Create a mock file system for testing.""" + fs = Mock() + fs.exists = Mock(return_value=True) + fs.read_text = Mock(return_value="test content") + fs.write_text = Mock(return_value=10) + fs.mkdir = Mock(return_value=True) + fs.rmdir = Mock(return_value=True) + fs.unlink = Mock(return_value=True) + return fs + + +@pytest.fixture +def mock_logger() -> Mock: + """Create a mock logger for testing.""" + logger = Mock() + logger.debug = Mock() + logger.info = Mock() + logger.warning = Mock() + logger.error = Mock() + logger.critical = Mock() + return logger + + +@pytest.fixture +def mock_config() -> Mock: + """Create a mock configuration for testing.""" + config = Mock() + config.get = Mock(return_value="default_value") + config.set = Mock(return_value=True) + config.has = Mock(return_value=True) + return config + + +@pytest.fixture +def mock_environment() -> Generator[dict[str, str], None, None]: + """Create a mock environment for testing.""" + original_env = os.environ.copy() + + test_env = { + "TEST_MODE": "true", + "TEST_DATABASE_URL": "sqlite:///:memory:", + "TEST_CACHE_URL": "memory://", + "TEST_MESSAGE_BROKER_URL": "memory://", + } + + os.environ.update(test_env) + + try: + yield test_env + finally: + os.environ.clear() + os.environ.update(original_env) diff --git a/packages/phenokit-config-kit/pytest/fixtures/performance.py b/packages/phenokit-config-kit/pytest/fixtures/performance.py new file mode 100644 index 0000000..5c74ef9 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/performance.py @@ -0,0 +1,183 @@ +""" +Performance testing fixtures for pytest. + +This module provides fixtures specifically for performance testing. +""" + +import threading +import time +from collections.abc import Generator +from contextlib import contextmanager +from typing import Any +from unittest.mock import Mock + +import psutil +import pytest + + +@pytest.fixture +def performance_monitor() -> Generator[dict[str, Any], None, None]: + """Monitor performance during test execution.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data = { + "start_time": start_time, + "start_memory": start_memory, + "peak_memory": start_memory, + "measurements": [], + "threads": [], + } + + def measure(name: str, **kwargs): + current_time = time.time() + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + measurement = { + "name": name, + "timestamp": current_time, + "memory": current_memory, + "duration": current_time - start_time, + **kwargs, + } + + monitor_data["measurements"].append(measurement) + + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + + return measurement + + def start_thread_monitoring(): + """Start background thread monitoring.""" + def monitor(): + while True: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + monitor_data["peak_memory"] = max(monitor_data["peak_memory"], current_memory) + time.sleep(0.1) + + thread = threading.Thread(target=monitor, daemon=True) + thread.start() + monitor_data["threads"].append(thread) + + monitor_data["measure"] = measure + monitor_data["start_thread_monitoring"] = start_thread_monitoring + + try: + yield monitor_data + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + monitor_data["end_time"] = end_time + monitor_data["end_memory"] = end_memory + monitor_data["total_duration"] = end_time - start_time + monitor_data["memory_delta"] = end_memory - start_memory + + +@pytest.fixture +def benchmark_data() -> Generator[dict[str, Any], None, None]: + """Provide benchmark data for testing.""" + return { + "iterations": 1000, + "warmup_iterations": 100, + "timeout": 60.0, + "threshold": 1.0, + "results": [], + } + + +@pytest.fixture +def load_test_data() -> Generator[dict[str, Any], None, None]: + """Provide load test data for testing.""" + return { + "users": 10, + "duration": 60, + "ramp_up": 10, + "ramp_down": 10, + "requests_per_second": 100, + "concurrent_requests": 50, + } + + +@pytest.fixture +def memory_profiler() -> Generator[dict[str, Any], None, None]: + """Provide memory profiling capabilities.""" + profiler_data = { + "snapshots": [], + "baseline": None, + "current": None, + } + + def take_snapshot(name: str): + snapshot = { + "name": name, + "timestamp": time.time(), + "memory": psutil.Process().memory_info().rss / 1024 / 1024, + "memory_percent": psutil.Process().memory_percent(), + "cpu_percent": psutil.Process().cpu_percent(), + } + profiler_data["snapshots"].append(snapshot) + profiler_data["current"] = snapshot + return snapshot + + def set_baseline(): + profiler_data["baseline"] = take_snapshot("baseline") + + def get_memory_delta(): + if profiler_data["baseline"] and profiler_data["current"]: + return profiler_data["current"]["memory"] - profiler_data["baseline"]["memory"] + return 0 + + profiler_data["take_snapshot"] = take_snapshot + profiler_data["set_baseline"] = set_baseline + profiler_data["get_memory_delta"] = get_memory_delta + + yield profiler_data + + +@pytest.fixture +def performance_thresholds() -> dict[str, float]: + """Provide performance thresholds for testing.""" + return { + "max_duration": 1.0, # seconds + "max_memory": 100.0, # MB + "max_cpu": 80.0, # percent + "max_memory_delta": 50.0, # MB + } + + +@pytest.fixture +def mock_performance_client() -> Mock: + """Create a mock performance client for testing.""" + client = Mock() + client.benchmark = Mock(return_value={"duration": 0.5, "memory": 50.0}) + client.profile = Mock(return_value={"cpu": 10.0, "memory": 50.0}) + client.load_test = Mock(return_value={"throughput": 100.0, "latency": 0.1}) + return client + + +@contextmanager +def performance_context(name: str, threshold: float = 1.0): + """Context manager for performance monitoring.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded for {name}: {duration:.2f}s > {threshold}s") + + print(f"Performance: {name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +@pytest.fixture +def performance_context_factory(): + """Factory for creating performance contexts.""" + return performance_context diff --git a/packages/phenokit-config-kit/pytest/fixtures/security.py b/packages/phenokit-config-kit/pytest/fixtures/security.py new file mode 100644 index 0000000..e2f5d5e --- /dev/null +++ b/packages/phenokit-config-kit/pytest/fixtures/security.py @@ -0,0 +1,196 @@ +""" +Security testing fixtures for pytest. + +This module provides fixtures specifically for security testing. +""" + +import hashlib +import hmac +from collections.abc import Generator +from typing import Any +from unittest.mock import Mock + +import pytest + + +@pytest.fixture +def security_context() -> Generator[dict[str, Any], None, None]: + """Provide security context for testing.""" + return { + "user_id": "test-user-123", + "organization_id": "test-org-123", + "roles": ["user"], + "permissions": ["read", "write"], + "auth_token": "test-token-123", + "session_id": "test-session-123", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "request_id": "test-request-123", + } + + +@pytest.fixture +def mock_auth_client() -> Mock: + """Create a mock authentication client for testing.""" + client = Mock() + client.authenticate = Mock(return_value={"success": True, "user_id": "test-user-123"}) + client.authorize = Mock(return_value={"success": True, "permissions": ["read", "write"]}) + client.validate_token = Mock(return_value={"valid": True, "user_id": "test-user-123"}) + client.refresh_token = Mock(return_value={"success": True, "token": "new-token-123"}) + client.logout = Mock(return_value={"success": True}) + return client + + +@pytest.fixture +def mock_security_scanner() -> Mock: + """Create a mock security scanner for testing.""" + scanner = Mock() + scanner.scan_vulnerabilities = Mock(return_value={"vulnerabilities": []}) + scanner.scan_dependencies = Mock(return_value={"vulnerabilities": []}) + scanner.scan_secrets = Mock(return_value={"secrets": []}) + scanner.scan_code = Mock(return_value={"issues": []}) + return scanner + + +@pytest.fixture +def test_credentials() -> dict[str, str]: + """Provide test credentials for testing.""" + return { + "username": "testuser", + "password": "testpassword123", + "email": "test@example.com", + "api_key": "test-api-key-123", + "secret": "test-secret-123", + } + + +@pytest.fixture +def test_tokens() -> dict[str, str]: + """Provide test tokens for testing.""" + return { + "access_token": "test-access-token-123", + "refresh_token": "test-refresh-token-123", + "id_token": "test-id-token-123", + "csrf_token": "test-csrf-token-123", + } + + +@pytest.fixture +def test_hashes() -> dict[str, str]: + """Provide test hashes for testing.""" + test_string = "test-string-123" + return { + "md5": hashlib.md5(test_string.encode()).hexdigest(), + "sha1": hashlib.sha1(test_string.encode()).hexdigest(), + "sha256": hashlib.sha256(test_string.encode()).hexdigest(), + "sha512": hashlib.sha512(test_string.encode()).hexdigest(), + "hmac": hmac.new(b"secret-key", test_string.encode(), hashlib.sha256).hexdigest(), + } + + +@pytest.fixture +def test_encryption_data() -> dict[str, Any]: + """Provide test encryption data for testing.""" + return { + "plaintext": "test-plaintext-123", + "ciphertext": "encrypted-test-data-123", + "key": "test-encryption-key-123", + "iv": "test-initialization-vector-123", + "algorithm": "AES-256-GCM", + } + + +@pytest.fixture +def test_sql_injection_payloads() -> list[str]: + """Provide test SQL injection payloads for testing.""" + return [ + "'; DROP TABLE users; --", + "1' OR '1'='1", + "1' UNION SELECT * FROM users --", + "1'; INSERT INTO users (username, password) VALUES ('hacker', 'password'); --", + "1' AND (SELECT COUNT(*) FROM users) > 0 --", + ] + + +@pytest.fixture +def test_xss_payloads() -> list[str]: + """Provide test XSS payloads for testing.""" + return [ + "", + "", + "", + "javascript:alert('XSS')", + "", + ] + + +@pytest.fixture +def test_csrf_payloads() -> list[str]: + """Provide test CSRF payloads for testing.""" + return [ + "
", + "
", + "", + ] + + +@pytest.fixture +def test_path_traversal_payloads() -> list[str]: + """Provide test path traversal payloads for testing.""" + return [ + "../../../etc/passwd", + "..\\..\\..\\windows\\system32\\drivers\\etc\\hosts", + "....//....//....//etc/passwd", + "%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd", + ] + + +@pytest.fixture +def test_command_injection_payloads() -> list[str]: + """Provide test command injection payloads for testing.""" + return [ + "; ls -la", + "| cat /etc/passwd", + "& whoami", + "`id`", + "$(id)", + "; rm -rf /", + ] + + +@pytest.fixture +def security_headers() -> dict[str, str]: + """Provide security headers for testing.""" + return { + "X-Content-Type-Options": "nosniff", + "X-Frame-Options": "DENY", + "X-XSS-Protection": "1; mode=block", + "Strict-Transport-Security": "max-age=31536000; includeSubDomains", + "Content-Security-Policy": "default-src 'self'", + "Referrer-Policy": "strict-origin-when-cross-origin", + "Permissions-Policy": "geolocation=(), microphone=(), camera=()", + } + + +@pytest.fixture +def mock_encryption_service() -> Mock: + """Create a mock encryption service for testing.""" + service = Mock() + service.encrypt = Mock(return_value="encrypted-data-123") + service.decrypt = Mock(return_value="decrypted-data-123") + service.generate_key = Mock(return_value="generated-key-123") + service.generate_iv = Mock(return_value="generated-iv-123") + return service + + +@pytest.fixture +def mock_audit_logger() -> Mock: + """Create a mock audit logger for testing.""" + logger = Mock() + logger.log_auth_success = Mock() + logger.log_auth_failure = Mock() + logger.log_permission_denied = Mock() + logger.log_security_event = Mock() + logger.log_data_access = Mock() + logger.log_data_modification = Mock() + return logger diff --git a/packages/phenokit-config-kit/pytest/parallel.ini b/packages/phenokit-config-kit/pytest/parallel.ini new file mode 100644 index 0000000..e89bfd7 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/parallel.ini @@ -0,0 +1,65 @@ +[pytest] +# Parallel execution configuration for different scenarios +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# === PARALLEL EXECUTION PROFILES === + +# Fast unit tests only (no external dependencies) +# Usage: pytest --ini=pytest-parallel.ini -m "fast and not slow and not serial" +addopts = + -v + --strict-markers + --tb=short + --disable-warnings + --strict-config + --maxfail=3 + --durations=5 + --durations-min=1.0 + -n auto + --dist=loadscope + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Warnings for parallel execution +filterwarnings = + ignore::DeprecationWarning:websockets.* + ignore::DeprecationWarning:pydantic.* + +# Parallel execution markers +markers = + fast: marks tests as fast unit tests (< 2s, no external deps, parallel-safe) + unit: marks tests as isolated unit tests (fast, no external dependencies) + integration: marks tests as integration tests that make real API calls + slow: marks tests as slow or long-running (>5 seconds) + serial: marks tests that must run serially due to shared resources + benchmark: marks performance/throughput benchmark tests + no_mock_provider: requires real provider calls (no mocks) + redis: tests that require Redis connection (serial) + nats: tests that require NATS messaging (serial) + oauth: tests for OAuth2/authentication flows + http: tests for HTTP server functionality + websocket: tests for WebSocket communication + mcp: tests for MCP protocol compliance + tool_specific: tests specific to individual tools + provider_specific: tests specific to AI model providers + workflow: tests for multi-step workflows + security: security-related tests + performance: performance testing and optimization + memory: memory usage and leak tests + concurrency: tests for concurrent operations + error_handling: tests focused on error scenarios + edge_case: tests for boundary conditions and edge cases + timeout: sets timeout for test execution + +# === EXECUTION COMMANDS === +# Fast parallel tests: pytest -m "fast and not serial" -n auto +# All unit tests parallel: pytest -m "unit and not serial" -n auto --maxfail=5 +# Serial tests only: pytest -m "serial" -n 0 +# Integration tests: pytest -m "integration" -n 4 --maxfail=2 +# All tests with grouping: pytest -n auto --dist=worksteal --maxfail=10 \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/performance.ini b/packages/phenokit-config-kit/pytest/performance.ini new file mode 100644 index 0000000..d12a006 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/performance.ini @@ -0,0 +1,64 @@ +[pytest] +# Performance testing configuration +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Performance testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=20 + --durations-min=0.1 + # Performance-specific options + --benchmark-only + --benchmark-sort=mean + --benchmark-skip + --benchmark-save=performance_results + --benchmark-save-data + # Memory profiling + --profile + --profile-svg + # Coverage for performance tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Performance testing markers +markers = + benchmark: Performance benchmark tests + performance: Performance testing and optimization + memory: Memory usage and leak tests + concurrency: Tests for concurrent operations + load: High concurrency load scenarios + stress: Stress testing under extreme conditions + profiling: Tests that require profiling + slow: Slow running tests (> 5 seconds) + fast: Fast running tests (< 1 second) + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + +# Performance-specific warnings +filterwarnings = + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::UserWarning:pytest_benchmark + ignore::UserWarning:memory_profiler + +# Timeout settings for performance tests +timeout = 600 +timeout_method = "thread" + +# Logging for performance analysis +log_cli = true +log_cli_level = INFO +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file diff --git a/packages/phenokit-config-kit/pytest/plugins/__init__.py b/packages/phenokit-config-kit/pytest/plugins/__init__.py new file mode 100644 index 0000000..1fe1989 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/__init__.py @@ -0,0 +1,15 @@ +""" +Pytest plugins for config-kit package. + +This module provides a collection of pytest plugins that can be used +across different Python projects. +""" + +__version__ = "0.1.0" +__all__ = [ + "ArchitecturePlugin", + "CoveragePlugin", + "PerformancePlugin", + "ReportingPlugin", + "SecurityPlugin", +] diff --git a/packages/phenokit-config-kit/pytest/plugins/architecture.py b/packages/phenokit-config-kit/pytest/plugins/architecture.py new file mode 100644 index 0000000..ecd9519 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/architecture.py @@ -0,0 +1,271 @@ +""" +Architecture testing plugin for pytest. + +This plugin provides architecture fitness tests including: +- File size validation +- Import boundary enforcement +- Dependency direction validation +- Naming convention checks +- Cyclomatic complexity analysis +""" + +import ast +import os +from pathlib import Path + +import pytest + + +class ArchitecturePlugin: + """Pytest plugin for architecture fitness testing.""" + + def __init__(self, config): + self.config = config + self.max_file_size = config.getoption("--max-file-size", default=1000) + self.max_complexity = config.getoption("--max-complexity", default=10) + self.enforce_imports = config.getoption("--enforce-imports", default=True) + self.enforce_dependencies = config.getoption("--enforce-dependencies", default=True) + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add architecture tests to the test collection.""" + if not self.enforce_imports and not self.enforce_dependencies: + return + + # Add architecture tests + architecture_items = [] + + if self.enforce_imports: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_import_boundaries", + callobj=self._test_import_boundaries, + markers=[pytest.mark.architecture, pytest.mark.imports], + ), + ) + + if self.enforce_dependencies: + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_dependency_direction", + callobj=self._test_dependency_direction, + markers=[pytest.mark.architecture, pytest.mark.dependencies], + ), + ) + + # Add file size tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_file_sizes", + callobj=self._test_file_sizes, + markers=[pytest.mark.architecture, pytest.mark.file_size], + ), + ) + + # Add complexity tests + architecture_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_cyclomatic_complexity", + callobj=self._test_cyclomatic_complexity, + markers=[pytest.mark.architecture, pytest.mark.complexity], + ), + ) + + items.extend(architecture_items) + + def _test_import_boundaries(self): + """Test that import boundaries are respected.""" + violations = [] + + # Define allowed import patterns + allowed_patterns = [ + r"^src\.", + r"^tests\.", + r"^conftest$", + ] + + # Check each Python file + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if not self._is_allowed_import(alias.name, allowed_patterns): + violations.append(f"{py_file}: {alias.name}") + elif isinstance(node, ast.ImportFrom): + if node.module and not self._is_allowed_import(node.module, allowed_patterns): + violations.append(f"{py_file}: {node.module}") + + if violations: + pytest.fail("Import boundary violations found:\n" + "\n".join(violations)) + + def _test_dependency_direction(self): + """Test that dependency direction is respected.""" + violations = [] + + # Define dependency rules + dependency_rules = { + "src.domain": [], # Domain should not depend on anything + "src.application": ["src.domain"], # Application can depend on domain + "src.adapters": ["src.domain", "src.application"], # Adapters can depend on domain and application + "src.infrastructure": ["src.domain", "src.application", "src.adapters"], # Infrastructure can depend on all + } + + for py_file in self._get_python_files(): + file_path = str(py_file) + if not file_path.startswith("src/"): + continue + + # Determine which layer this file belongs to + file_layer = self._get_file_layer(file_path) + if not file_layer: + continue + + # Check imports against dependency rules + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + import_layer = self._get_import_layer(node.module) + if import_layer and not self._is_allowed_dependency(file_layer, import_layer, dependency_rules): + violations.append(f"{py_file}: {file_layer} -> {import_layer} ({node.module})") + + if violations: + pytest.fail("Dependency direction violations found:\n" + "\n".join(violations)) + + def _test_file_sizes(self): + """Test that file sizes are within limits.""" + violations = [] + + for py_file in self._get_python_files(): + line_count = sum(1 for _ in open(py_file, encoding="utf-8")) + if line_count > self.max_file_size: + violations.append(f"{py_file}: {line_count} lines (max: {self.max_file_size})") + + if violations: + pytest.fail("File size violations found:\n" + "\n".join(violations)) + + def _test_cyclomatic_complexity(self): + """Test that cyclomatic complexity is within limits.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + complexity = self._calculate_complexity(node) + if complexity > self.max_complexity: + violations.append(f"{py_file}:{node.lineno} {node.name}: {complexity} (max: {self.max_complexity})") + + if violations: + pytest.fail("Cyclomatic complexity violations found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in os.walk("."): + # Skip certain directories + dirs[:] = [d for d in dirs if d not in {".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build"}] + + for file in files: + if file.endswith(".py"): + python_files.append(Path(root) / file) + + return python_files + + def _is_allowed_import(self, import_name: str, allowed_patterns: list[str]) -> bool: + """Check if an import is allowed based on patterns.""" + import re + return any(re.match(pattern, import_name) for pattern in allowed_patterns) + + def _get_file_layer(self, file_path: str) -> str | None: + """Determine which architectural layer a file belongs to.""" + if "src/domain" in file_path: + return "src.domain" + if "src/application" in file_path: + return "src.application" + if "src/adapters" in file_path: + return "src.adapters" + if "src/infrastructure" in file_path: + return "src.infrastructure" + return None + + def _get_import_layer(self, import_name: str) -> str | None: + """Determine which architectural layer an import belongs to.""" + if import_name.startswith("src.domain"): + return "src.domain" + if import_name.startswith("src.application"): + return "src.application" + if import_name.startswith("src.adapters"): + return "src.adapters" + if import_name.startswith("src.infrastructure"): + return "src.infrastructure" + return None + + def _is_allowed_dependency(self, from_layer: str, to_layer: str, rules: dict[str, list[str]]) -> bool: + """Check if a dependency from one layer to another is allowed.""" + allowed_deps = rules.get(from_layer, []) + return to_layer in allowed_deps + + def _calculate_complexity(self, node: ast.AST) -> int: + """Calculate cyclomatic complexity of a function.""" + complexity = 1 # Base complexity + + for child in ast.walk(node): + if isinstance(child, (ast.If, ast.While, ast.For, ast.AsyncFor)) or isinstance(child, ast.ExceptHandler): + complexity += 1 + elif isinstance(child, ast.BoolOp): + complexity += len(child.values) - 1 + + return complexity + + +def pytest_addoption(parser): + """Add command line options for architecture testing.""" + parser.addoption( + "--max-file-size", + type=int, + default=1000, + help="Maximum allowed lines per file", + ) + parser.addoption( + "--max-complexity", + type=int, + default=10, + help="Maximum allowed cyclomatic complexity", + ) + parser.addoption( + "--enforce-imports", + action="store_true", + default=True, + help="Enforce import boundaries", + ) + parser.addoption( + "--enforce-dependencies", + action="store_true", + default=True, + help="Enforce dependency direction", + ) + + +def pytest_configure(config): + """Configure the architecture plugin.""" + config.pluginmanager.register(ArchitecturePlugin(config), "architecture") diff --git a/packages/phenokit-config-kit/pytest/plugins/performance.py b/packages/phenokit-config-kit/pytest/plugins/performance.py new file mode 100644 index 0000000..268ddc3 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/performance.py @@ -0,0 +1,166 @@ +""" +Performance testing plugin for pytest. + +This plugin provides performance testing capabilities including: +- Benchmark testing +- Memory profiling +- Performance regression detection +- Load testing utilities +""" + +import threading +import time +from collections.abc import Callable +from contextlib import contextmanager + +import psutil +import pytest + + +class PerformancePlugin: + """Pytest plugin for performance testing.""" + + def __init__(self, config): + self.config = config + self.benchmark_threshold = config.getoption("--benchmark-threshold", default=1.0) + self.memory_threshold = config.getoption("--memory-threshold", default=100) # MB + self.performance_data = {} + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_setup(self, item): + """Setup performance monitoring for each test.""" + if hasattr(item, "get_closest_marker") and item.get_closest_marker("performance"): + self.performance_data[item.nodeid] = { + "start_time": time.time(), + "start_memory": psutil.Process().memory_info().rss / 1024 / 1024, # MB + "peak_memory": 0, + } + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_teardown(self, item): + """Teardown performance monitoring for each test.""" + if item.nodeid in self.performance_data: + data = self.performance_data[item.nodeid] + data["end_time"] = time.time() + data["duration"] = data["end_time"] - data["start_time"] + data["end_memory"] = psutil.Process().memory_info().rss / 1024 / 1024 # MB + data["memory_delta"] = data["end_memory"] - data["start_memory"] + + @pytest.hookimpl(tryfirst=True) + def pytest_runtest_call(self, item): + """Monitor performance during test execution.""" + if item.nodeid in self.performance_data: + # Monitor memory usage during test execution + def monitor_memory(): + while item.nodeid in self.performance_data: + current_memory = psutil.Process().memory_info().rss / 1024 / 1024 + self.performance_data[item.nodeid]["peak_memory"] = max(self.performance_data[item.nodeid]["peak_memory"], current_memory) + time.sleep(0.1) + + monitor_thread = threading.Thread(target=monitor_memory, daemon=True) + monitor_thread.start() + + def pytest_collection_modifyitems(self, config, items): + """Add performance validation tests.""" + performance_items = [] + + # Add benchmark validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_benchmark_performance", + callobj=self._test_benchmark_performance, + markers=[pytest.mark.performance, pytest.mark.benchmark], + ), + ) + + # Add memory usage validation + performance_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_memory_usage", + callobj=self._test_memory_usage, + markers=[pytest.mark.performance, pytest.mark.memory], + ), + ) + + items.extend(performance_items) + + def _test_benchmark_performance(self): + """Test that benchmark performance meets thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "duration" in data and data["duration"] > self.benchmark_threshold: + violations.append(f"{test_id}: {data['duration']:.2f}s (threshold: {self.benchmark_threshold}s)") + + if violations: + pytest.fail("Benchmark performance violations found:\n" + "\n".join(violations)) + + def _test_memory_usage(self): + """Test that memory usage is within thresholds.""" + violations = [] + + for test_id, data in self.performance_data.items(): + if "peak_memory" in data and data["peak_memory"] > self.memory_threshold: + violations.append(f"{test_id}: {data['peak_memory']:.2f}MB (threshold: {self.memory_threshold}MB)") + + if violations: + pytest.fail("Memory usage violations found:\n" + "\n".join(violations)) + + +@contextmanager +def performance_monitor(test_name: str, threshold: float = 1.0): + """Context manager for monitoring performance of code blocks.""" + start_time = time.time() + start_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + try: + yield + finally: + end_time = time.time() + end_memory = psutil.Process().memory_info().rss / 1024 / 1024 + + duration = end_time - start_time + memory_delta = end_memory - start_memory + + if duration > threshold: + pytest.fail(f"Performance threshold exceeded: {duration:.2f}s > {threshold}s") + + print(f"Performance: {test_name} - Duration: {duration:.2f}s, Memory: {memory_delta:.2f}MB") + + +def benchmark_test(func: Callable) -> Callable: + """Decorator for marking functions as benchmark tests.""" + return pytest.mark.performance(func) + + +def memory_test(func: Callable) -> Callable: + """Decorator for marking functions as memory tests.""" + return pytest.mark.memory(func) + + +def load_test(users: int = 10, duration: int = 60) -> Callable: + """Decorator for marking functions as load tests.""" + return pytest.mark.performance(pytest.mark.parametrize("load_users", [users])(func)) + + +def pytest_addoption(parser): + """Add command line options for performance testing.""" + parser.addoption( + "--benchmark-threshold", + type=float, + default=1.0, + help="Maximum allowed execution time for benchmark tests (seconds)", + ) + parser.addoption( + "--memory-threshold", + type=float, + default=100, + help="Maximum allowed memory usage (MB)", + ) + + +def pytest_configure(config): + """Configure the performance plugin.""" + config.pluginmanager.register(PerformancePlugin(config), "performance") diff --git a/packages/phenokit-config-kit/pytest/plugins/security.py b/packages/phenokit-config-kit/pytest/plugins/security.py new file mode 100644 index 0000000..b1d9690 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/plugins/security.py @@ -0,0 +1,243 @@ +""" +Security testing plugin for pytest. + +This plugin provides security testing capabilities including: +- Security vulnerability scanning +- Authentication testing +- Authorization testing +- Input validation testing +- Security compliance checking +""" + +import ast +import re +from pathlib import Path + +import pytest + + +class SecurityPlugin: + """Pytest plugin for security testing.""" + + def __init__(self, config): + self.config = config + self.security_rules = self._load_security_rules() + self.vulnerability_patterns = self._load_vulnerability_patterns() + + def _load_security_rules(self) -> dict[str, list[str]]: + """Load security rules configuration.""" + return { + "forbidden_imports": [ + "pickle", + "marshal", + "shelve", + "dbm", + "sqlite3", + "subprocess", + "os.system", + "eval", + "exec", + "compile", + ], + "forbidden_functions": [ + "eval", + "exec", + "compile", + "input", + "raw_input", + "reload", + "__import__", + ], + "required_imports": [ + "hashlib", + "secrets", + "hmac", + ], + "password_patterns": [ + r"password\s*=\s*['\"][^'\"]+['\"]", + r"passwd\s*=\s*['\"][^'\"]+['\"]", + r"pwd\s*=\s*['\"][^'\"]+['\"]", + ], + "hardcoded_secrets": [ + r"api_key\s*=\s*['\"][^'\"]+['\"]", + r"secret\s*=\s*['\"][^'\"]+['\"]", + r"token\s*=\s*['\"][^'\"]+['\"]", + r"key\s*=\s*['\"][^'\"]+['\"]", + ], + } + + def _load_vulnerability_patterns(self) -> dict[str, str]: + """Load vulnerability detection patterns.""" + return { + "sql_injection": r"(SELECT|INSERT|UPDATE|DELETE|DROP|CREATE|ALTER).*\+.*%", + "xss": r".*", + "path_traversal": r"\.\./", + "command_injection": r"(os\.system|subprocess\.call|subprocess\.run)", + "unsafe_deserialization": r"(pickle\.loads|marshal\.loads)", + "weak_crypto": r"(md5|sha1|des|rc4)", + "hardcoded_credentials": r"(username|password|api_key|secret)\s*=\s*['\"][^'\"]+['\"]", + } + + @pytest.hookimpl(tryfirst=True) + def pytest_collection_modifyitems(self, config, items): + """Add security validation tests.""" + security_items = [] + + # Add vulnerability scanning + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_vulnerability_scan", + callobj=self._test_vulnerability_scan, + markers=[pytest.mark.security, pytest.mark.vulnerability], + ), + ) + + # Add forbidden imports check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_forbidden_imports", + callobj=self._test_forbidden_imports, + markers=[pytest.mark.security, pytest.mark.imports], + ), + ) + + # Add hardcoded secrets check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_hardcoded_secrets", + callobj=self._test_hardcoded_secrets, + markers=[pytest.mark.security, pytest.mark.secrets], + ), + ) + + # Add password security check + security_items.append( + pytest.Function.from_parent( + parent=items[0].parent if items else None, + name="test_password_security", + callobj=self._test_password_security, + markers=[pytest.mark.security, pytest.mark.passwords], + ), + ) + + items.extend(security_items) + + def _test_vulnerability_scan(self): + """Scan code for common vulnerabilities.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for vuln_type, pattern in self.vulnerability_patterns.items(): + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: {vuln_type} - {matches}") + + if violations: + pytest.fail("Security vulnerabilities found:\n" + "\n".join(violations)) + + def _test_forbidden_imports(self): + """Check for forbidden imports that could be security risks.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + try: + tree = ast.parse(f.read()) + except SyntaxError: + continue + + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{alias.name}'") + elif isinstance(node, ast.ImportFrom): + if node.module in self.security_rules["forbidden_imports"]: + violations.append(f"{py_file}: Forbidden import '{node.module}'") + + if violations: + pytest.fail("Forbidden imports found:\n" + "\n".join(violations)) + + def _test_hardcoded_secrets(self): + """Check for hardcoded secrets in the code.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["hardcoded_secrets"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Hardcoded secret - {matches}") + + if violations: + pytest.fail("Hardcoded secrets found:\n" + "\n".join(violations)) + + def _test_password_security(self): + """Check for insecure password handling.""" + violations = [] + + for py_file in self._get_python_files(): + with open(py_file, encoding="utf-8") as f: + content = f.read() + + for pattern in self.security_rules["password_patterns"]: + matches = re.findall(pattern, content, re.IGNORECASE) + if matches: + violations.append(f"{py_file}: Insecure password handling - {matches}") + + if violations: + pytest.fail("Insecure password handling found:\n" + "\n".join(violations)) + + def _get_python_files(self) -> list[Path]: + """Get all Python files in the project.""" + python_files = [] + for root, dirs, files in Path().rglob("*.py"): + # Skip certain directories + if any(skip in str(root) for skip in (".git", "__pycache__", ".pytest_cache", "htmlcov", "dist", "build")): + continue + python_files.append(root) + + return python_files + + +def security_test(func: Callable) -> Callable: + """Decorator for marking functions as security tests.""" + return pytest.mark.security(func) + + +def auth_test(func: Callable) -> Callable: + """Decorator for marking functions as authentication tests.""" + return pytest.mark.auth(func) + + +def injection_test(func: Callable) -> Callable: + """Decorator for marking functions as injection tests.""" + return pytest.mark.injection(func) + + +def xss_test(func: Callable) -> Callable: + """Decorator for marking functions as XSS tests.""" + return pytest.mark.xss(func) + + +def csrf_test(func: Callable) -> Callable: + """Decorator for marking functions as CSRF tests.""" + return pytest.mark.csrf(func) + + +def sql_injection_test(func: Callable) -> Callable: + """Decorator for marking functions as SQL injection tests.""" + return pytest.mark.sql_injection(func) + + +def pytest_configure(config): + """Configure the security plugin.""" + config.pluginmanager.register(SecurityPlugin(config), "security") diff --git a/packages/phenokit-config-kit/pytest/security.ini b/packages/phenokit-config-kit/pytest/security.ini new file mode 100644 index 0000000..f6d4da8 --- /dev/null +++ b/packages/phenokit-config-kit/pytest/security.ini @@ -0,0 +1,75 @@ +[pytest] +# Security testing configuration +minversion = 7.4 +testpaths = tests +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Security testing options +addopts = + -v + --tb=short + --strict-markers + --maxfail=5 + --durations=10 + --durations-min=1.0 + # Security-specific options + --bandit + --bandit-config=.bandit + --safety + --safety-json + # Coverage for security tests + --cov=src + --cov-report=term-missing + --cov-report=html:htmlcov + --cov-report=xml:coverage.xml + +# Asyncio configuration +asyncio_mode = auto +asyncio_default_fixture_loop_scope = session + +# Security testing markers +markers = + security: Security-related tests + auth: Authentication and authorization checks + rls: Tests that validate RLS policies + compliance: Compliance and regulatory tests + vulnerability: Vulnerability testing + penetration: Penetration testing + injection: Injection attack testing + xss: Cross-site scripting testing + csrf: Cross-site request forgery testing + sql_injection: SQL injection testing + authentication: Authentication testing + authorization: Authorization testing + encryption: Encryption testing + hashing: Hashing testing + jwt: JWT token testing + oauth: OAuth2/authentication flows + session: Session management testing + input_validation: Input validation testing + output_encoding: Output encoding testing + error_handling: Security error handling + logging: Security logging testing + audit: Security audit testing + unit: Unit tests (fast, isolated, no I/O) + integration: Integration tests (slower, may use I/O) + +# Security-specific warnings +filterwarnings = + error + ignore::DeprecationWarning + ignore::PendingDeprecationWarning + ignore::UserWarning:bandit + ignore::UserWarning:safety + +# Timeout settings for security tests +timeout = 300 +timeout_method = "thread" + +# Logging for security analysis +log_cli = true +log_cli_level = WARNING +log_cli_format = "%(asctime)s [%(levelname)8s] %(name)s: %(message)s" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a228001..c536849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ phenotype-id = { workspace = true } phenotype-logging = { workspace = true } phenotype-py-kit = { workspace = true } phenotype-testing = { workspace = true } +phenokit-config-kit = { workspace = true } qa-kit = { workspace = true } [tool.uv.workspace] @@ -108,6 +109,11 @@ members = [ 'packages/phenotype-py-kit', 'packages/phenotype-testing', 'packages/phenotype-config', + # phenokit-config-kit: intake from KooshaPari/phenotype-sdk Phase 3 (decomp). + # Linting/pre-commit/pytest scaffolds — intentionally distinct from + # packages/phenotype-config (which is Pydantic-settings). See packages/ + # phenokit-config-kit/ORIGIN.md. + 'packages/phenokit-config-kit', ] exclude = [ 'packages/auth-kit',