skilldrop-cli 0.16.28 → 0.16.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. package/bin/skilldrop.js +160 -33
  2. package/catalogue.json +7 -3
  3. package/model-routing.json +16 -0
  4. package/package.json +1 -1
  5. package/packs/ai-engineering/skills/mcp-pairing/SKILL.md +93 -0
  6. package/packs/ai-engineering/skills/mcp-pairing/evals/eval_queries.json +13 -0
  7. package/packs/ai-engineering/skills/mcp-pairing/evals/evals.json +76 -0
  8. package/packs/ai-engineering/skills/mcp-pairing/evals/files/2/mcp.json +32 -0
  9. package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/.mcp.json +47 -0
  10. package/packs/ai-engineering/skills/mcp-pairing/evals/files/4/docs/pr-loop.md +12 -0
  11. package/packs/ai-engineering/skills/mcp-pairing/evals/files/5/docs/oncall-triage.md +18 -0
  12. package/packs/ai-engineering/skills/mcp-pairing/examples/build-loop-github-jira-datadog.md +69 -0
  13. package/packs/ai-engineering/skills/mcp-pairing/manifest.json +17 -0
  14. package/packs/ai-engineering/skills/mcp-pairing/reference.md +136 -0
  15. package/packs/ai-engineering/skills/mcp-pairing/templates/mcp-pairing.md +43 -0
  16. package/packs/data-analytics/loops/measure/LOOP.md +118 -0
  17. package/packs/data-analytics/loops/measure/evals/eval_queries.json +9 -0
  18. package/packs/data-analytics/loops/measure/evals/evals.json +28 -0
  19. package/packs/data-analytics/loops/measure/loop.json +41 -0
  20. package/packs/dev-team/loops/build/LOOP.md +4 -2
  21. package/packs/dev-team/loops/build/loop.json +2 -2
  22. package/packs/dev-team/skills/codebase-tour/SKILL.md +115 -0
  23. package/packs/dev-team/skills/codebase-tour/evals/eval_queries.json +13 -0
  24. package/packs/dev-team/skills/codebase-tour/evals/evals.json +77 -0
  25. package/packs/dev-team/skills/codebase-tour/evals/files/1/.github/workflows/ci.yml +23 -0
  26. package/packs/dev-team/skills/codebase-tour/evals/files/1/README.md +23 -0
  27. package/packs/dev-team/skills/codebase-tour/evals/files/1/openapi.yaml +21 -0
  28. package/packs/dev-team/skills/codebase-tour/evals/files/1/package-lock.json +7 -0
  29. package/packs/dev-team/skills/codebase-tour/evals/files/1/package.json +29 -0
  30. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/config.ts +20 -0
  31. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/filter.ts +24 -0
  32. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/domain/shipments/repository.ts +15 -0
  33. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/errors.ts +9 -0
  34. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/generated/api-types.ts +9 -0
  35. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/handlers/shipments.ts +38 -0
  36. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/http/routes.ts +7 -0
  37. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/jobs/export-shipments.ts +22 -0
  38. package/packs/dev-team/skills/codebase-tour/evals/files/1/src/server.ts +18 -0
  39. package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/domain/filter.test.ts +16 -0
  40. package/packs/dev-team/skills/codebase-tour/evals/files/1/tests/http/shipments.test.ts +18 -0
  41. package/packs/dev-team/skills/codebase-tour/evals/files/1/tsconfig.json +10 -0
  42. package/packs/dev-team/skills/codebase-tour/evals/files/1/vitest.config.ts +5 -0
  43. package/packs/dev-team/skills/codebase-tour/evals/files/2/.github/workflows/ci.yml +16 -0
  44. package/packs/dev-team/skills/codebase-tour/evals/files/2/README.md +16 -0
  45. package/packs/dev-team/skills/codebase-tour/evals/files/2/noxfile.py +14 -0
  46. package/packs/dev-team/skills/codebase-tour/evals/files/2/pyproject.toml +22 -0
  47. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/__init__.py +3 -0
  48. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/_version.py +4 -0
  49. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/cli.py +32 -0
  50. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/detect.py +15 -0
  51. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/errors.py +2 -0
  52. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/money.py +14 -0
  53. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/__init__.py +8 -0
  54. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/acme.py +18 -0
  55. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/base.py +24 -0
  56. package/packs/dev-team/skills/codebase-tour/evals/files/2/src/ledgerline/parsers/harbor.py +13 -0
  57. package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/fixtures/acme_sample.csv +3 -0
  58. package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_acme.py +23 -0
  59. package/packs/dev-team/skills/codebase-tour/evals/files/2/tests/test_detect.py +13 -0
  60. package/packs/dev-team/skills/codebase-tour/evals/files/3/.github/workflows/ci.yml +28 -0
  61. package/packs/dev-team/skills/codebase-tour/evals/files/3/Makefile +16 -0
  62. package/packs/dev-team/skills/codebase-tour/evals/files/3/README.md +16 -0
  63. package/packs/dev-team/skills/codebase-tour/evals/files/3/cmd/tidepool/main.go +24 -0
  64. package/packs/dev-team/skills/codebase-tour/evals/files/3/go.mod +5 -0
  65. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/config/config.go +38 -0
  66. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher.go +52 -0
  67. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/dispatcher_test.go +42 -0
  68. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/dispatch/sender.go +32 -0
  69. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/replay/replay.go +31 -0
  70. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/db.go +35 -0
  71. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql +11 -0
  72. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/query.sql.go +30 -0
  73. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/schema.sql +6 -0
  74. package/packs/dev-team/skills/codebase-tour/evals/files/3/internal/store/store_integration_test.go +21 -0
  75. package/packs/dev-team/skills/codebase-tour/evals/files/3/sqlc.yaml +9 -0
  76. package/packs/dev-team/skills/codebase-tour/evals/files/4/.env.example +4 -0
  77. package/packs/dev-team/skills/codebase-tour/evals/files/4/.github/workflows/ci.yml +16 -0
  78. package/packs/dev-team/skills/codebase-tour/evals/files/4/Makefile +16 -0
  79. package/packs/dev-team/skills/codebase-tour/evals/files/4/README.md +23 -0
  80. package/packs/dev-team/skills/codebase-tour/evals/files/4/data/rates.csv +4 -0
  81. package/packs/dev-team/skills/codebase-tour/evals/files/4/pyproject.toml +19 -0
  82. package/packs/dev-team/skills/codebase-tour/evals/files/4/scripts/gen_rates.py +29 -0
  83. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/__init__.py +0 -0
  84. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/__init__.py +0 -0
  85. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/preview.py +14 -0
  86. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/api/routes.py +36 -0
  87. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/config.py +15 -0
  88. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/discounts.py +8 -0
  89. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/__init__.py +0 -0
  90. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/generated/rates_table.py +8 -0
  91. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/main.py +8 -0
  92. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/shipping.py +18 -0
  93. package/packs/dev-team/skills/codebase-tour/evals/files/4/src/shipcalc/tax.py +7 -0
  94. package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/conftest.py +3 -0
  95. package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_discounts.py +11 -0
  96. package/packs/dev-team/skills/codebase-tour/evals/files/4/tests/unit/test_tax.py +13 -0
  97. package/packs/dev-team/skills/codebase-tour/evals/files/5/.env.example +3 -0
  98. package/packs/dev-team/skills/codebase-tour/evals/files/5/.github/workflows/ci.yml +13 -0
  99. package/packs/dev-team/skills/codebase-tour/evals/files/5/README.md +18 -0
  100. package/packs/dev-team/skills/codebase-tour/evals/files/5/package.json +13 -0
  101. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/openapi.json +30 -0
  102. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/package.json +15 -0
  103. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/db.js +19 -0
  104. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/flags.js +8 -0
  105. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/generated/schema.js +32 -0
  106. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/middleware/validate.js +20 -0
  107. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/routes/bookings.js +15 -0
  108. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/server.js +15 -0
  109. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/bookings.js +23 -0
  110. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/src/services/pricing.js +9 -0
  111. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/api/test/pricing.test.js +15 -0
  112. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/package.json +9 -0
  113. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/bookingForm.js +11 -0
  114. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/src/format.js +7 -0
  115. package/packs/dev-team/skills/codebase-tour/evals/files/5/packages/web/test/format.test.js +12 -0
  116. package/packs/dev-team/skills/codebase-tour/evals/files/5/scripts/gen-schema.mjs +27 -0
  117. package/packs/dev-team/skills/codebase-tour/examples/refund-window.md +158 -0
  118. package/packs/dev-team/skills/codebase-tour/manifest.json +47 -0
  119. package/packs/dev-team/skills/codebase-tour/scripts/repo_facts.py +403 -0
  120. package/packs/dev-team/skills/codebase-tour/templates/codebase-tour.md +85 -0
  121. package/packs/dev-team/skills/dependency-upgrade-review/SKILL.md +101 -0
  122. package/packs/dev-team/skills/dependency-upgrade-review/evals/eval_queries.json +11 -0
  123. package/packs/dev-team/skills/dependency-upgrade-review/evals/evals.json +77 -0
  124. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/.github/workflows/ci.yml +20 -0
  125. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/pr.diff +87 -0
  126. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/quillmark-CHANGELOG.md +24 -0
  127. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/comment.js +6 -0
  128. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/src/render/note.js +8 -0
  129. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/1/test/note.test.js +12 -0
  130. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/ci.yml +23 -0
  131. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/.github/workflows/release.yml +23 -0
  132. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/pr.diff +32 -0
  133. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/2/release-notes.md +20 -0
  134. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/.github/workflows/ci.yml +18 -0
  135. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/Dockerfile +8 -0
  136. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/PR.md +36 -0
  137. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/pr.diff +47 -0
  138. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/client.py +24 -0
  139. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/src/ingest/reader.py +32 -0
  140. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/tests/test_reader.py +15 -0
  141. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/4/upstream/quillstream-CHANGELOG.md +37 -0
  142. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/ci.yml +22 -0
  143. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.github/workflows/release.yml +25 -0
  144. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/.nvmrc +1 -0
  145. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-412.md +16 -0
  146. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/PR-413.md +15 -0
  147. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/package.json +21 -0
  148. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-412.diff +26 -0
  149. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/pr-413.diff +84 -0
  150. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/scripts/release.mjs +18 -0
  151. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/release-notary-CHANGELOG.md +16 -0
  152. package/packs/dev-team/skills/dependency-upgrade-review/evals/files/5/upstream/setup-toolchain-RELEASES.md +22 -0
  153. package/packs/dev-team/skills/dependency-upgrade-review/examples/inputs/ledgerline.diff +38 -0
  154. package/packs/dev-team/skills/dependency-upgrade-review/examples/ledgerline-tidyparse.md +91 -0
  155. package/packs/dev-team/skills/dependency-upgrade-review/manifest.json +50 -0
  156. package/packs/dev-team/skills/dependency-upgrade-review/reference.md +99 -0
  157. package/packs/dev-team/skills/dependency-upgrade-review/scripts/upgrade_diff.py +718 -0
  158. package/packs/grc/loops/comply/LOOP.md +133 -0
  159. package/packs/grc/loops/comply/evals/eval_queries.json +9 -0
  160. package/packs/grc/loops/comply/evals/evals.json +30 -0
  161. package/packs/grc/loops/comply/loop.json +54 -0
  162. package/packs/product-manager/loops/discover/LOOP.md +5 -4
  163. package/packs/product-manager/loops/discover/loop.json +2 -2
  164. package/packs/product-manager/skills/feedback-synthesis/SKILL.md +182 -0
  165. package/packs/product-manager/skills/feedback-synthesis/evals/eval_queries.json +46 -0
  166. package/packs/product-manager/skills/feedback-synthesis/evals/evals.json +76 -0
  167. package/packs/product-manager/skills/feedback-synthesis/evals/files/1/interview-notes.md +47 -0
  168. package/packs/product-manager/skills/feedback-synthesis/evals/files/1/nps-verbatims.csv +13 -0
  169. package/packs/product-manager/skills/feedback-synthesis/evals/files/1/support-tickets.csv +36 -0
  170. package/packs/product-manager/skills/feedback-synthesis/evals/files/3/churn-exit-survey.csv +11 -0
  171. package/packs/product-manager/skills/feedback-synthesis/evals/files/3/sales-call-notes.md +55 -0
  172. package/packs/product-manager/skills/feedback-synthesis/evals/files/4/csat_comments_q3.csv +17 -0
  173. package/packs/product-manager/skills/feedback-synthesis/evals/files/4/support_tickets_q3.csv +49 -0
  174. package/packs/product-manager/skills/feedback-synthesis/evals/files/5/app_reviews_last_60_days.csv +32 -0
  175. package/packs/product-manager/skills/feedback-synthesis/evals/files/5/interview_notes_round1.md +48 -0
  176. package/packs/product-manager/skills/feedback-synthesis/examples/booking-app-feedback.md +139 -0
  177. package/packs/product-manager/skills/feedback-synthesis/examples/inputs/booking-app-coded.csv +17 -0
  178. package/packs/product-manager/skills/feedback-synthesis/manifest.json +53 -0
  179. package/packs/product-manager/skills/feedback-synthesis/reference.md +89 -0
  180. package/packs/product-manager/skills/feedback-synthesis/scripts/theme_counts.py +355 -0
  181. package/packs/product-manager/skills/feedback-synthesis/templates/coding-sheet.csv +6 -0
  182. package/packs/product-manager/skills/feedback-synthesis/templates/synthesis.md +42 -0
  183. package/packs/research/loops/research/LOOP.md +123 -0
  184. package/packs/research/loops/research/evals/eval_queries.json +9 -0
  185. package/packs/research/loops/research/evals/evals.json +39 -0
  186. package/packs/research/loops/research/loop.json +35 -0
@@ -0,0 +1,22 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "setuptools-scm>=8"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "ledgerline"
7
+ dynamic = ["version"]
8
+ requires-python = ">=3.10"
9
+ dependencies = []
10
+
11
+ [project.optional-dependencies]
12
+ dev = ["pytest>=8", "ruff>=0.4", "nox>=2024.3"]
13
+
14
+ [project.scripts]
15
+ ledgerline = "ledgerline.cli:main"
16
+
17
+ [tool.setuptools_scm]
18
+ version_file = "src/ledgerline/_version.py"
19
+
20
+ [tool.pytest.ini_options]
21
+ testpaths = ["tests"]
22
+ markers = ["slow: reads the large sample statements"]
@@ -0,0 +1,3 @@
1
+ from ledgerline._version import __version__
2
+
3
+ __all__ = ["__version__"]
@@ -0,0 +1,4 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+ __version__ = version = "1.6.2.dev4"
4
+ __version_tuple__ = version_tuple = (1, 6, 2, "dev4")
@@ -0,0 +1,32 @@
1
+ import argparse
2
+ import csv
3
+ import os
4
+ import sys
5
+
6
+ from ledgerline.detect import detect_bank
7
+ from ledgerline.errors import ParseError
8
+ from ledgerline.parsers import PARSERS
9
+
10
+
11
+ def main(argv=None):
12
+ ap = argparse.ArgumentParser(prog="ledgerline")
13
+ ap.add_argument("statement")
14
+ ap.add_argument("--bank", help="skip detection and use this parser")
15
+ args = ap.parse_args(argv)
16
+ currency = os.environ.get("LEDGERLINE_CURRENCY", "GBP")
17
+ with open(args.statement, newline="", encoding="utf-8-sig") as fh:
18
+ rows = list(csv.reader(fh))
19
+ bank = args.bank or detect_bank(rows[0])
20
+ parser = PARSERS[bank](currency=currency)
21
+ writer = csv.writer(sys.stdout)
22
+ for n, row in enumerate(rows[1:], start=2):
23
+ try:
24
+ writer.writerow(parser.parse_row(row).as_csv())
25
+ except ParseError as e:
26
+ print(f"line {n}: {e}", file=sys.stderr)
27
+ return 1
28
+ return 0
29
+
30
+
31
+ if __name__ == "__main__":
32
+ sys.exit(main())
@@ -0,0 +1,15 @@
1
+ from ledgerline.errors import ParseError
2
+
3
+ # Header row of each bank's export -> parser name in PARSERS.
4
+ HEADER_SIGNATURES = {
5
+ ("Date", "Description", "Amount", "Balance"): "acme",
6
+ ("Posted", "Payee", "Debit", "Credit"): "harbor",
7
+ }
8
+
9
+
10
+ def detect_bank(header):
11
+ key = tuple(cell.strip() for cell in header)
12
+ try:
13
+ return HEADER_SIGNATURES[key]
14
+ except KeyError:
15
+ raise ParseError(f"unrecognised header {list(key)}; pass --bank")
@@ -0,0 +1,2 @@
1
+ class ParseError(ValueError):
2
+ """A statement row that cannot be turned into an Entry. The message names the column."""
@@ -0,0 +1,14 @@
1
+ from decimal import Decimal, InvalidOperation
2
+
3
+ from ledgerline.errors import ParseError
4
+
5
+
6
+ def parse_amount(text: str) -> Decimal:
7
+ """Parse '1,234.50' or '(12.00)' into a Decimal. Parentheses mean a debit."""
8
+ cleaned = text.strip().replace(",", "")
9
+ negative = cleaned.startswith("(") and cleaned.endswith(")")
10
+ try:
11
+ value = Decimal(cleaned.strip("()"))
12
+ except InvalidOperation:
13
+ raise ParseError(f"amount {text!r} is not a number")
14
+ return -value if negative else value
@@ -0,0 +1,8 @@
1
+ from ledgerline.parsers.acme import AcmeParser
2
+ from ledgerline.parsers.harbor import HarborParser
3
+
4
+ # Explicit registry: a parser module is not used until it is listed here.
5
+ PARSERS = {
6
+ "acme": AcmeParser,
7
+ "harbor": HarborParser,
8
+ }
@@ -0,0 +1,18 @@
1
+ from datetime import datetime
2
+
3
+ from ledgerline.errors import ParseError
4
+ from ledgerline.money import parse_amount
5
+ from ledgerline.parsers.base import BaseParser, Entry
6
+
7
+
8
+ class AcmeParser(BaseParser):
9
+ date_format = "%d/%m/%Y"
10
+
11
+ def parse_row(self, row):
12
+ if len(row) != 4:
13
+ raise ParseError(f"expected 4 columns, got {len(row)}")
14
+ try:
15
+ on = datetime.strptime(row[0], self.date_format).date()
16
+ except ValueError:
17
+ raise ParseError(f"Date {row[0]!r} is not DD/MM/YYYY")
18
+ return Entry(on=on, payee=row[1].strip(), amount=parse_amount(row[2]), currency=self.currency)
@@ -0,0 +1,24 @@
1
+ from dataclasses import dataclass
2
+ from datetime import date
3
+ from decimal import Decimal
4
+
5
+
6
+ @dataclass(frozen=True)
7
+ class Entry:
8
+ on: date
9
+ payee: str
10
+ amount: Decimal
11
+ currency: str
12
+
13
+ def as_csv(self):
14
+ return [self.on.isoformat(), self.payee, f"{self.amount:.2f}", self.currency]
15
+
16
+
17
+ class BaseParser:
18
+ date_format = "%Y-%m-%d"
19
+
20
+ def __init__(self, currency: str):
21
+ self.currency = currency
22
+
23
+ def parse_row(self, row) -> Entry:
24
+ raise NotImplementedError
@@ -0,0 +1,13 @@
1
+ from datetime import datetime
2
+ from decimal import Decimal
3
+
4
+ from ledgerline.parsers.base import BaseParser, Entry
5
+
6
+
7
+ class HarborParser(BaseParser):
8
+ def parse_row(self, row):
9
+ on = datetime.strptime(row[0], self.date_format).date()
10
+ # Harbor splits debit and credit into two columns.
11
+ debit = Decimal(row[2].replace(",", "") or "0")
12
+ credit = Decimal(row[3].replace(",", "") or "0")
13
+ return Entry(on=on, payee=row[1].strip(), amount=credit - debit, currency=self.currency)
@@ -0,0 +1,3 @@
1
+ Date,Description,Amount,Balance
2
+ 02/03/2026,Corner Bakery,4.20,995.80
3
+ 03/03/2026,Refund Northwind Books,(12.00),983.80
@@ -0,0 +1,23 @@
1
+ from datetime import date
2
+ from decimal import Decimal
3
+ from pathlib import Path
4
+ import csv
5
+
6
+ import pytest
7
+
8
+ from ledgerline.errors import ParseError
9
+ from ledgerline.parsers.acme import AcmeParser
10
+
11
+ FIXTURES = Path(__file__).parent / "fixtures"
12
+
13
+
14
+ def test_parses_sample_rows():
15
+ rows = list(csv.reader(open(FIXTURES / "acme_sample.csv", encoding="utf-8")))
16
+ entries = [AcmeParser(currency="GBP").parse_row(r) for r in rows[1:]]
17
+ assert entries[0].on == date(2026, 3, 2)
18
+ assert entries[1].amount == Decimal("-12.00")
19
+
20
+
21
+ def test_bad_date_names_the_column():
22
+ with pytest.raises(ParseError, match="Date"):
23
+ AcmeParser(currency="GBP").parse_row(["2026-03-02", "Cafe", "3.10", "0"])
@@ -0,0 +1,13 @@
1
+ import pytest
2
+
3
+ from ledgerline.detect import detect_bank
4
+ from ledgerline.errors import ParseError
5
+
6
+
7
+ def test_detects_acme_header():
8
+ assert detect_bank(["Date", "Description", "Amount", "Balance"]) == "acme"
9
+
10
+
11
+ def test_unknown_header_tells_the_user_to_pass_bank():
12
+ with pytest.raises(ParseError, match="--bank"):
13
+ detect_bank(["When", "What", "How much"])
@@ -0,0 +1,28 @@
1
+ name: ci
2
+ on: [push, pull_request]
3
+ jobs:
4
+ unit:
5
+ runs-on: ubuntu-latest
6
+ steps:
7
+ - uses: actions/checkout@v4
8
+ - uses: actions/setup-go@v5
9
+ with:
10
+ go-version: "1.22"
11
+ - run: make lint
12
+ - run: make test
13
+ integration:
14
+ runs-on: ubuntu-latest
15
+ services:
16
+ postgres:
17
+ image: postgres:16
18
+ env:
19
+ POSTGRES_PASSWORD: example-only
20
+ ports: ["5432:5432"]
21
+ env:
22
+ TIDEPOOL_DATABASE_URL: postgres://postgres:example-only@localhost:5432/postgres?sslmode=disable
23
+ steps:
24
+ - uses: actions/checkout@v4
25
+ - uses: actions/setup-go@v5
26
+ with:
27
+ go-version: "1.22"
28
+ - run: make test-integration
@@ -0,0 +1,16 @@
1
+ .PHONY: build test test-integration generate lint
2
+
3
+ build:
4
+ go build -o bin/tidepool ./cmd/tidepool
5
+
6
+ test:
7
+ go test ./...
8
+
9
+ test-integration:
10
+ go test -tags=integration ./internal/store/...
11
+
12
+ generate:
13
+ sqlc generate
14
+
15
+ lint:
16
+ golangci-lint run
@@ -0,0 +1,16 @@
1
+ # tidepool
2
+
3
+ Delivers webhook events to tenant endpoints, with retries.
4
+
5
+ ## Configuration
6
+
7
+ Retries are set in `config.yaml`:
8
+
9
+ ```yaml
10
+ max_attempts: 5
11
+ retry_delay: 2s
12
+ ```
13
+
14
+ ## Development
15
+
16
+ `make test` runs the full test suite, including the database tests.
@@ -0,0 +1,24 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "log"
6
+ "net/http"
7
+
8
+ "example.com/tidepool/internal/config"
9
+ "example.com/tidepool/internal/dispatch"
10
+ "example.com/tidepool/internal/store"
11
+ )
12
+
13
+ func main() {
14
+ cfg, err := config.FromEnv()
15
+ if err != nil {
16
+ log.Fatalf("config: %v", err)
17
+ }
18
+ db, err := store.Open(context.Background(), cfg.DatabaseURL)
19
+ if err != nil {
20
+ log.Fatalf("store: %v", err)
21
+ }
22
+ d := dispatch.New(cfg, store.New(db), dispatch.NewHTTPSender(http.DefaultClient))
23
+ log.Fatal(d.Run(context.Background()))
24
+ }
@@ -0,0 +1,5 @@
1
+ module example.com/tidepool
2
+
3
+ go 1.22
4
+
5
+ require github.com/jackc/pgx/v5 v5.5.5
@@ -0,0 +1,38 @@
1
+ package config
2
+
3
+ import (
4
+ "fmt"
5
+ "os"
6
+ "strconv"
7
+ "time"
8
+ )
9
+
10
+ type Config struct {
11
+ DatabaseURL string
12
+ MaxAttempts int
13
+ RetryDelay time.Duration
14
+ }
15
+
16
+ // FromEnv reads every setting from the environment. There is no config file.
17
+ func FromEnv() (Config, error) {
18
+ cfg := Config{MaxAttempts: 5, RetryDelay: 2 * time.Second}
19
+ cfg.DatabaseURL = os.Getenv("TIDEPOOL_DATABASE_URL")
20
+ if cfg.DatabaseURL == "" {
21
+ return cfg, fmt.Errorf("TIDEPOOL_DATABASE_URL is required")
22
+ }
23
+ if v, ok := os.LookupEnv("TIDEPOOL_MAX_ATTEMPTS"); ok {
24
+ n, err := strconv.Atoi(v)
25
+ if err != nil {
26
+ return cfg, fmt.Errorf("TIDEPOOL_MAX_ATTEMPTS: %w", err)
27
+ }
28
+ cfg.MaxAttempts = n
29
+ }
30
+ if v, ok := os.LookupEnv("TIDEPOOL_RETRY_DELAY"); ok {
31
+ d, err := time.ParseDuration(v)
32
+ if err != nil {
33
+ return cfg, fmt.Errorf("TIDEPOOL_RETRY_DELAY: %w", err)
34
+ }
35
+ cfg.RetryDelay = d
36
+ }
37
+ return cfg, nil
38
+ }
@@ -0,0 +1,52 @@
1
+ package dispatch
2
+
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ "time"
7
+
8
+ "example.com/tidepool/internal/config"
9
+ "example.com/tidepool/internal/store"
10
+ )
11
+
12
+ type Dispatcher struct {
13
+ cfg config.Config
14
+ q *store.Queries
15
+ sender Sender
16
+ sleep func(time.Duration)
17
+ }
18
+
19
+ func New(cfg config.Config, q *store.Queries, s Sender) *Dispatcher {
20
+ return &Dispatcher{cfg: cfg, q: q, sender: s, sleep: time.Sleep}
21
+ }
22
+
23
+ // Deliver posts one event, retrying with a fixed delay until MaxAttempts.
24
+ func (d *Dispatcher) Deliver(ctx context.Context, ev store.Event) error {
25
+ var last error
26
+ for attempt := 1; attempt <= d.cfg.MaxAttempts; attempt++ {
27
+ if last = d.sender.Send(ctx, ev.EndpointURL, ev.Payload); last == nil {
28
+ return nil
29
+ }
30
+ if attempt < d.cfg.MaxAttempts {
31
+ d.sleep(d.cfg.RetryDelay)
32
+ }
33
+ }
34
+ return fmt.Errorf("event %d: gave up after %d attempts: %w", ev.ID, d.cfg.MaxAttempts, last)
35
+ }
36
+
37
+ func (d *Dispatcher) Run(ctx context.Context) error {
38
+ for {
39
+ events, err := d.q.PendingEvents(ctx, 100)
40
+ if err != nil {
41
+ return fmt.Errorf("pending events: %w", err)
42
+ }
43
+ for _, ev := range events {
44
+ if err := d.Deliver(ctx, ev); err != nil {
45
+ _ = d.q.MarkDead(ctx, ev.ID)
46
+ continue
47
+ }
48
+ _ = d.q.MarkDelivered(ctx, ev.ID)
49
+ }
50
+ d.sleep(time.Second)
51
+ }
52
+ }
@@ -0,0 +1,42 @@
1
+ package dispatch
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+ "time"
8
+
9
+ "example.com/tidepool/internal/config"
10
+ "example.com/tidepool/internal/store"
11
+ )
12
+
13
+ type flakySender struct{ failures, calls int }
14
+
15
+ func (f *flakySender) Send(context.Context, string, []byte) error {
16
+ f.calls++
17
+ if f.calls <= f.failures {
18
+ return errors.New("boom")
19
+ }
20
+ return nil
21
+ }
22
+
23
+ func TestDeliverRetriesUntilSuccess(t *testing.T) {
24
+ var slept []time.Duration
25
+ s := &flakySender{failures: 2}
26
+ d := &Dispatcher{cfg: config.Config{MaxAttempts: 5, RetryDelay: time.Second}, sender: s,
27
+ sleep: func(t time.Duration) { slept = append(slept, t) }}
28
+ if err := d.Deliver(context.Background(), store.Event{ID: 1}); err != nil {
29
+ t.Fatalf("unexpected error: %v", err)
30
+ }
31
+ if s.calls != 3 || len(slept) != 2 {
32
+ t.Fatalf("calls=%d slept=%v", s.calls, slept)
33
+ }
34
+ }
35
+
36
+ func TestDeliverGivesUpAtMaxAttempts(t *testing.T) {
37
+ s := &flakySender{failures: 10}
38
+ d := &Dispatcher{cfg: config.Config{MaxAttempts: 3}, sender: s, sleep: func(time.Duration) {}}
39
+ if err := d.Deliver(context.Background(), store.Event{ID: 7}); err == nil || s.calls != 3 {
40
+ t.Fatalf("err=%v calls=%d", err, s.calls)
41
+ }
42
+ }
@@ -0,0 +1,32 @@
1
+ package dispatch
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "fmt"
7
+ "net/http"
8
+ )
9
+
10
+ type Sender interface {
11
+ Send(ctx context.Context, url string, body []byte) error
12
+ }
13
+
14
+ type HTTPSender struct{ client *http.Client }
15
+
16
+ func NewHTTPSender(c *http.Client) *HTTPSender { return &HTTPSender{client: c} }
17
+
18
+ func (s *HTTPSender) Send(ctx context.Context, url string, body []byte) error {
19
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
20
+ if err != nil {
21
+ return fmt.Errorf("build request: %w", err)
22
+ }
23
+ resp, err := s.client.Do(req)
24
+ if err != nil {
25
+ return fmt.Errorf("post %s: %w", url, err)
26
+ }
27
+ defer resp.Body.Close()
28
+ if resp.StatusCode >= 300 {
29
+ return fmt.Errorf("post %s: status %d", url, resp.StatusCode)
30
+ }
31
+ return nil
32
+ }
@@ -0,0 +1,31 @@
1
+ // Package replay re-sends dead-lettered events when an operator asks for it.
2
+ package replay
3
+
4
+ import (
5
+ "context"
6
+ "fmt"
7
+ "time"
8
+
9
+ "example.com/tidepool/internal/config"
10
+ "example.com/tidepool/internal/dispatch"
11
+ "example.com/tidepool/internal/store"
12
+ )
13
+
14
+ // Replay sends each dead event again. It keeps its own retry loop so an operator
15
+ // replay never blocks on the dispatcher's queue.
16
+ func Replay(ctx context.Context, cfg config.Config, q *store.Queries, s dispatch.Sender) error {
17
+ events, err := q.DeadEvents(ctx)
18
+ if err != nil {
19
+ return fmt.Errorf("dead events: %w", err)
20
+ }
21
+ for _, ev := range events {
22
+ for attempt := 1; attempt <= cfg.MaxAttempts; attempt++ {
23
+ if err := s.Send(ctx, ev.EndpointURL, ev.Payload); err == nil {
24
+ _ = q.MarkDelivered(ctx, ev.ID)
25
+ break
26
+ }
27
+ time.Sleep(cfg.RetryDelay)
28
+ }
29
+ }
30
+ return nil
31
+ }
@@ -0,0 +1,35 @@
1
+ package store
2
+
3
+ import (
4
+ "context"
5
+
6
+ "github.com/jackc/pgx/v5/pgxpool"
7
+ )
8
+
9
+ type Queries struct{ pool *pgxpool.Pool }
10
+
11
+ func Open(ctx context.Context, url string) (*pgxpool.Pool, error) { return pgxpool.New(ctx, url) }
12
+
13
+ func New(pool *pgxpool.Pool) *Queries { return &Queries{pool: pool} }
14
+
15
+ func (q *Queries) list(ctx context.Context, sql string, args ...any) ([]Event, error) {
16
+ rows, err := q.pool.Query(ctx, sql, args...)
17
+ if err != nil {
18
+ return nil, err
19
+ }
20
+ defer rows.Close()
21
+ var out []Event
22
+ for rows.Next() {
23
+ var e Event
24
+ if err := rows.Scan(&e.ID, &e.EndpointURL, &e.Payload); err != nil {
25
+ return nil, err
26
+ }
27
+ out = append(out, e)
28
+ }
29
+ return out, rows.Err()
30
+ }
31
+
32
+ func (q *Queries) exec(ctx context.Context, sql string, args ...any) error {
33
+ _, err := q.pool.Exec(ctx, sql, args...)
34
+ return err
35
+ }
@@ -0,0 +1,11 @@
1
+ -- name: PendingEvents :many
2
+ SELECT id, endpoint_url, payload FROM events WHERE state = 'pending' ORDER BY id LIMIT $1;
3
+
4
+ -- name: DeadEvents :many
5
+ SELECT id, endpoint_url, payload FROM events WHERE state = 'dead' ORDER BY id;
6
+
7
+ -- name: MarkDelivered :exec
8
+ UPDATE events SET state = 'delivered' WHERE id = $1;
9
+
10
+ -- name: MarkDead :exec
11
+ UPDATE events SET state = 'dead' WHERE id = $1;
@@ -0,0 +1,30 @@
1
+ // Code generated by sqlc. DO NOT EDIT.
2
+ // versions:
3
+ // sqlc v1.26.0
4
+ // source: query.sql
5
+
6
+ package store
7
+
8
+ import "context"
9
+
10
+ type Event struct {
11
+ ID int64
12
+ EndpointURL string
13
+ Payload []byte
14
+ }
15
+
16
+ func (q *Queries) PendingEvents(ctx context.Context, limit int32) ([]Event, error) {
17
+ return q.list(ctx, "SELECT id, endpoint_url, payload FROM events WHERE state = 'pending' ORDER BY id LIMIT $1", limit)
18
+ }
19
+
20
+ func (q *Queries) DeadEvents(ctx context.Context) ([]Event, error) {
21
+ return q.list(ctx, "SELECT id, endpoint_url, payload FROM events WHERE state = 'dead' ORDER BY id")
22
+ }
23
+
24
+ func (q *Queries) MarkDelivered(ctx context.Context, id int64) error {
25
+ return q.exec(ctx, "UPDATE events SET state = 'delivered' WHERE id = $1", id)
26
+ }
27
+
28
+ func (q *Queries) MarkDead(ctx context.Context, id int64) error {
29
+ return q.exec(ctx, "UPDATE events SET state = 'dead' WHERE id = $1", id)
30
+ }
@@ -0,0 +1,6 @@
1
+ CREATE TABLE events (
2
+ id BIGSERIAL PRIMARY KEY,
3
+ endpoint_url TEXT NOT NULL,
4
+ payload BYTEA NOT NULL,
5
+ state TEXT NOT NULL DEFAULT 'pending'
6
+ );
@@ -0,0 +1,21 @@
1
+ //go:build integration
2
+
3
+ package store
4
+
5
+ import (
6
+ "context"
7
+ "os"
8
+ "testing"
9
+ )
10
+
11
+ func TestMarkDeadMovesEventOutOfPending(t *testing.T) {
12
+ ctx := context.Background()
13
+ pool, err := Open(ctx, os.Getenv("TIDEPOOL_DATABASE_URL"))
14
+ if err != nil {
15
+ t.Fatal(err)
16
+ }
17
+ q := New(pool)
18
+ if _, err := q.PendingEvents(ctx, 1); err != nil {
19
+ t.Fatal(err)
20
+ }
21
+ }
@@ -0,0 +1,9 @@
1
+ version: "2"
2
+ sql:
3
+ - engine: postgresql
4
+ queries: internal/store/query.sql
5
+ schema: internal/store/schema.sql
6
+ gen:
7
+ go:
8
+ package: store
9
+ out: internal/store
@@ -0,0 +1,4 @@
1
+ # Required: the service refuses to start without it (see src/shipcalc/config.py)
2
+ SHIPCALC_API_KEY=replace-me
3
+ # Optional
4
+ SHIPCALC_LOG_LEVEL=info
@@ -0,0 +1,16 @@
1
+ name: ci
2
+ on:
3
+ pull_request:
4
+ push:
5
+ branches: [main]
6
+ jobs:
7
+ test:
8
+ runs-on: ubuntu-latest
9
+ steps:
10
+ - uses: actions/checkout@v4
11
+ - uses: actions/setup-python@v5
12
+ with:
13
+ python-version: "3.12"
14
+ - run: make install
15
+ - run: make check-generated
16
+ - run: make test
@@ -0,0 +1,16 @@
1
+ .PHONY: install run test gen check-generated
2
+
3
+ install:
4
+ pip install -e ".[dev]"
5
+
6
+ run:
7
+ SHIPCALC_API_KEY=dev-placeholder uvicorn shipcalc.main:app --reload --port 8080
8
+
9
+ test:
10
+ SHIPCALC_API_KEY=test-placeholder pytest -q
11
+
12
+ gen:
13
+ python scripts/gen_rates.py data/rates.csv src/shipcalc/generated/rates_table.py
14
+
15
+ check-generated: gen
16
+ git diff --exit-code src/shipcalc/generated/