Skip to content

Commit 2d1aa47

Browse files
fix: split prod/dev requirements to shrink Heroku slug under 1GB
Move torch/sentence-transformers and importer/test tooling into requirements-dev.txt, keep a slim requirements.txt for Heroku, and lazy-import heavy deps so gunicorn boot no longer pulls them. Closes #975. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 3e37395 commit 2d1aa47

18 files changed

Lines changed: 331 additions & 168 deletions

.github/workflows/linter.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ jobs:
4343
python -m venv venv
4444
. ./venv/bin/activate
4545
pip install --upgrade pip setuptools
46-
pip install -r requirements.txt
46+
pip install -r requirements-dev.txt
4747
- name: OpenAPI guardrail
4848
run: make openapi-guardrail
4949
- name: Lint Code Base

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ cover:
108108
install-deps-python:
109109
[ -d "./venv" ] && . ./venv/bin/activate &&\
110110
pip install --upgrade pip setuptools &&\
111-
pip install -r requirements.txt
111+
pip install -r requirements-dev.txt
112112

113113
install-deps-typescript:
114114
(cd application/frontend && yarn install)

application/cmd/cre_main.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,12 @@
99
import requests
1010

1111
from collections import deque
12-
from typing import Any, Callable, Dict, List, Optional, Tuple
12+
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING
1313
import hashlib
1414
import json as _json
1515
from rq import Queue, job, exceptions
1616
from sqlalchemy import not_
1717

18-
from application.utils.external_project_parsers.base_parser import BaseParser
19-
from application.utils.external_project_parsers.parsers import master_spreadsheet_parser
2018
from application import create_app # type: ignore
2119
from application.config import CMDConfig
2220
from application.database import db
@@ -26,11 +24,12 @@
2624
from application.utils import spreadsheet as sheet_utils
2725
from application.utils import redis
2826
from application.utils import db_backend
29-
from alive_progress import alive_bar
30-
from application.prompt_client import prompt_client as prompt_client
3127
from application.utils import gap_analysis
3228
from application.utils import cres_csv_export
3329

30+
if TYPE_CHECKING:
31+
from application.prompt_client import prompt_client as prompt_client
32+
3433
logging.basicConfig()
3534
logger = logging.getLogger(__name__)
3635
logger.setLevel(logging.INFO)
@@ -469,6 +468,8 @@ def _standard_structure_fingerprint(resource_name: str) -> str:
469468
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
470469

471470
conn = redis.connect()
471+
from application.prompt_client import prompt_client as prompt_client
472+
472473
ph = prompt_client.PromptHandler(database=collection)
473474
importing_name = standard_entries[0].name
474475
effective_calculate_gap_analysis = (
@@ -541,7 +542,7 @@ def _standard_structure_fingerprint(resource_name: str) -> str:
541542
def parse_standards_from_spreadsheeet(
542543
cre_file: List[Dict[str, Any]],
543544
cache_location: str,
544-
prompt_handler: prompt_client.PromptHandler,
545+
prompt_handler: "prompt_client.PromptHandler",
545546
) -> None:
546547
"""given a csv with standards, build a list of standards in the db"""
547548
if not cre_file:
@@ -568,6 +569,10 @@ def parse_standards_from_spreadsheeet(
568569
from application.utils import import_pipeline
569570

570571
collection = db_connect(cache_location)
572+
from application.utils.external_project_parsers.parsers import (
573+
master_spreadsheet_parser,
574+
)
575+
571576
parse_result = master_spreadsheet_parser.MasterSpreadsheetParser.parse_rows(
572577
cre_file
573578
)
@@ -682,6 +687,8 @@ def download_gap_analysis_from_upstream(cache: str) -> None:
682687
pairs = [(sa, sb) for sa in standards for sb in standards if sa != sb]
683688

684689
if os.environ.get("BENCHMARK_MODE") == "1":
690+
from alive_progress import alive_bar
691+
685692
with alive_bar(len(pairs), title="Fetching upstream Gap Analysis") as bar:
686693
for sa, sb in pairs:
687694
res = requests.get(
@@ -846,11 +853,15 @@ def backfill_gap_analysis_only(
846853
jobs.append(j)
847854

848855
if jobs:
856+
from alive_progress import alive_bar
857+
849858
with alive_bar(
850859
len(jobs), title=f"GA batch {i // batch_size + 1}"
851860
) as bar:
852861
redis.wait_for_jobs(jobs, bar)
853862
else:
863+
from alive_progress import alive_bar
864+
854865
with alive_bar(len(batch), title=f"GA batch {i // batch_size + 1}") as bar:
855866
for sa, sb in batch:
856867
_compute_pair_direct(collection, sa, sb)
@@ -901,6 +912,8 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
901912
cache.delete_nodes(args.delete_resource)
902913

903914
# individual resource importing
915+
from application.utils.external_project_parsers.base_parser import BaseParser
916+
904917
if args.zap_in:
905918
from application.utils.external_project_parsers.parsers import zap_alerts_parser
906919

@@ -1014,6 +1027,8 @@ def run(args: argparse.Namespace) -> None: # pragma: no cover
10141027

10151028

10161029
def ai_client_init(database: db.Node_collection):
1030+
from application.prompt_client import prompt_client as prompt_client
1031+
10171032
return prompt_client.PromptHandler(database=database)
10181033

10191034

@@ -1053,6 +1068,8 @@ def prepare_for_review(cache: str) -> Tuple[str, str]:
10531068

10541069

10551070
def generate_embeddings(db_url: str) -> None:
1071+
from application.prompt_client import prompt_client as prompt_client
1072+
10561073
database = db_connect(path=db_url)
10571074
prompt_client.PromptHandler(database, load_all_embeddings=True)
10581075

@@ -1109,6 +1126,8 @@ def run_librarian(
11091126
"land W8); running in dry-run mode"
11101127
)
11111128

1129+
from application.prompt_client import prompt_client as prompt_client
1130+
11121131
cfg = load_config()
11131132
database = db_connect(path=cache_file)
11141133
ph = prompt_client.PromptHandler(database=database)
@@ -1208,6 +1227,8 @@ def run_librarian(
12081227

12091228
def regenerate_embeddings(db_url: str) -> None:
12101229
"""Wipe all embedding rows, then rebuild (CRE + every node type) like ``--generate_embeddings``."""
1230+
from application.prompt_client import prompt_client as prompt_client
1231+
12111232
database = db_connect(path=db_url)
12121233
removed = database.delete_all_embeddings()
12131234
logger.info("Removed %s embedding rows; rebuilding embeddings", removed)

application/prompt_client/prompt_client.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@
22
from application.defs import cre_defs
33
from datetime import datetime
44
from multiprocessing import Pool
5-
from nltk.corpus import stopwords
6-
from nltk.tokenize import word_tokenize
75
from io import BytesIO
86
from urllib.parse import urlparse
97

108
from application.prompt_client import embed_alignment
119

12-
from playwright.sync_api import Error as PlaywrightError
13-
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError, sync_playwright
1410
from scipy import sparse
1511
from sklearn.metrics.pairwise import cosine_similarity
1612
from typing import Dict, List, Any, Tuple, Optional
@@ -22,7 +18,6 @@
2218
from pypdf import PdfReader
2319
except ImportError:
2420
PdfReader = None # type: ignore[misc, assignment]
25-
import nltk
2621
import numpy as np
2722
import os
2823
import json
@@ -247,6 +242,11 @@ def get_content(self, url) -> Optional[str]:
247242
)
248243
continue
249244

245+
# Playwright is for scrape/import embedding generation only — not chat.
246+
# Import after the PDF branch so PDF-only extraction works without Playwright.
247+
from playwright.sync_api import Error as PlaywrightError
248+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
249+
250250
page = None
251251
try:
252252
page = self.__context.new_page()
@@ -301,6 +301,9 @@ def get_html(self, url) -> Optional[str]:
301301
for attempts in range(1, 10):
302302
if _is_likely_pdf_url(url):
303303
return None
304+
from playwright.sync_api import Error as PlaywrightError
305+
from playwright.sync_api import TimeoutError as PlaywrightTimeoutError
306+
304307
page = None
305308
try:
306309
page = self.__context.new_page()
@@ -343,6 +346,8 @@ def _ensure_smart_embed_caches(self) -> None:
343346
] = {}
344347

345348
def clean_content(self, content):
349+
from nltk.tokenize import word_tokenize # lazy: scrape/import path only
350+
346351
content = re.sub("\s+", " ", content.strip())
347352

348353
# split into words
@@ -363,6 +368,9 @@ def with_ai_client(self, ai_client):
363368

364369
def setup_playwright(self):
365370
# in case we want to run without connectivity to ai_client or playwright
371+
import nltk
372+
from playwright.sync_api import sync_playwright
373+
366374
self.__playwright = sync_playwright().start()
367375
nltk.download("punkt")
368376
nltk.download("punkt_tab")

application/tests/chat_completion_test.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,48 @@ def test_completion_returns_500_on_non_429_genai_error(self) -> None:
7676
self.assertIn("error", data)
7777
self.assertIn("AI Service Error", data["error"])
7878

79+
def test_completion_returns_503_when_prompt_client_import_fails(self) -> None:
80+
os.environ["NO_LOGIN"] = "1"
81+
with patch(
82+
"application.prompt_client.prompt_client.PromptHandler",
83+
side_effect=ImportError("nltk"),
84+
):
85+
with self.app.test_client() as client:
86+
response = client.post(
87+
"/rest/v1/completion",
88+
json={"prompt": "test"},
89+
content_type="application/json",
90+
)
91+
self.assertEqual(503, response.status_code)
92+
93+
def test_completion_returns_503_when_litellm_missing(self) -> None:
94+
os.environ["NO_LOGIN"] = "1"
95+
with patch(
96+
"application.prompt_client.prompt_client.PromptHandler",
97+
side_effect=RuntimeError("litellm package is required for PromptHandler"),
98+
):
99+
with self.app.test_client() as client:
100+
response = client.post(
101+
"/rest/v1/completion",
102+
json={"prompt": "test"},
103+
content_type="application/json",
104+
)
105+
self.assertEqual(503, response.status_code)
106+
107+
def test_completion_propagates_unrelated_runtime_error(self) -> None:
108+
os.environ["NO_LOGIN"] = "1"
109+
with patch(
110+
"application.prompt_client.prompt_client.PromptHandler",
111+
side_effect=RuntimeError("database exploded"),
112+
):
113+
with self.app.test_client() as client:
114+
with self.assertRaises(RuntimeError):
115+
client.post(
116+
"/rest/v1/completion",
117+
json={"prompt": "test"},
118+
content_type="application/json",
119+
)
120+
79121

80122
if __name__ == "__main__":
81123
unittest.main()

application/tests/spreadsheet_test.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -112,8 +112,8 @@ def test_prepare_spreadsheet(self) -> None:
112112

113113
self.assertCountEqual(result, expected)
114114

115-
@mock.patch("application.utils.spreadsheet.gspread.service_account")
116-
@mock.patch("application.utils.spreadsheet.gspread.oauth")
115+
@mock.patch("gspread.service_account")
116+
@mock.patch("gspread.oauth")
117117
def test_read_spreadsheet_iso_numbers(
118118
self, mock_oauth, mock_service_account
119119
) -> None:
@@ -165,8 +165,8 @@ def test_read_spreadsheet_iso_numbers(
165165

166166
self.assertEqual(result["ISO Numericise Test"], expected)
167167

168-
@mock.patch("application.utils.spreadsheet.gspread.service_account")
169-
@mock.patch("application.utils.spreadsheet.gspread.oauth")
168+
@mock.patch("gspread.service_account")
169+
@mock.patch("gspread.oauth")
170170
def test_read_spreadsheet_empty_worksheet(
171171
self, mock_oauth, mock_service_account
172172
) -> None:
@@ -191,8 +191,8 @@ def test_read_spreadsheet_empty_worksheet(
191191

192192
self.assertEqual(result["Empty Sheet"], [])
193193

194-
@mock.patch("application.utils.spreadsheet.gspread.service_account")
195-
@mock.patch("application.utils.spreadsheet.gspread.oauth")
194+
@mock.patch("gspread.service_account")
195+
@mock.patch("gspread.oauth")
196196
def test_read_spreadsheet_short_row_padded(
197197
self, mock_oauth, mock_service_account
198198
) -> None:
@@ -221,15 +221,15 @@ def test_read_spreadsheet_short_row_padded(
221221
self.assertEqual(result["Short Row"], [{"Col A": "only-a", "Col B": ""}])
222222

223223
def test_records_from_worksheet_values_duplicate_headers(self) -> None:
224-
with self.assertRaises(gspread.exceptions.GSpreadException) as ctx:
224+
with self.assertRaises(ValueError) as ctx:
225225
_records_from_worksheet_values(
226226
[["Col A", "Col B", "Col A"], ["x", "y", "z"]]
227227
)
228228
self.assertIn("Duplicate worksheet headers", str(ctx.exception))
229229
self.assertIn("Col A", str(ctx.exception))
230230

231-
@mock.patch("application.utils.spreadsheet.gspread.service_account")
232-
@mock.patch("application.utils.spreadsheet.gspread.oauth")
231+
@mock.patch("gspread.service_account")
232+
@mock.patch("gspread.oauth")
233233
def test_read_spreadsheet_duplicate_headers(
234234
self, mock_oauth, mock_service_account
235235
) -> None:

application/tests/web_main_test.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -797,7 +797,9 @@ def test_gap_analysis_create_job_id(
797797
self.assertTrue(enqueue_call_mock.called)
798798
_, kwargs = enqueue_call_mock.call_args
799799
self.assertEqual("aaa->bbb", kwargs["description"])
800-
self.assertEqual(cre_main.run_gap_pair_job, kwargs["func"])
800+
self.assertEqual(
801+
"application.cmd.cre_main.run_gap_pair_job", kwargs["func"]
802+
)
801803
self.assertEqual("aaa", kwargs["kwargs"]["importing_name"])
802804
self.assertEqual("bbb", kwargs["kwargs"]["peer_name"])
803805
self.assertEqual(GAP_ANALYSIS_TIMEOUT, kwargs["timeout"])

application/utils/external_project_parsers/base_parser.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
from application.utils.external_project_parsers import base_parser_defs
22
from rq import Queue
33
from application.utils import redis
4-
from application.prompt_client import prompt_client as prompt_client
54
import logging
65
import time
7-
from alive_progress import alive_bar
86
from application.utils.external_project_parsers.parsers import *
97
from application.utils import gap_analysis
108
import os, json
@@ -22,6 +20,7 @@ def register_resource(
2220
db_connection_str: str,
2321
):
2422
from application.cmd import cre_main
23+
from application.prompt_client import prompt_client as prompt_client
2524

2625
db = cre_main.db_connect(db_connection_str)
2726

@@ -55,6 +54,8 @@ def call_importers(self, db_connection_str: str):
5554
somehow finds all the importers that have been registered (either reflection for implementing classes or an explicit method that registers all available importers)
5655
and schedules jobs to call those importers, monitors the jobs and alerts when done same as cre_main
5756
"""
57+
from alive_progress import alive_bar
58+
5859
importers = []
5960
jobs = []
6061
conn = redis.connect()

application/utils/external_project_parsers/base_parser_defs.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
from typing import List, Dict, Optional
1+
from typing import List, Dict, Optional, TYPE_CHECKING
22
from dataclasses import dataclass
33
from enum import Enum
44

55
from application.defs import cre_defs as defs
6-
from application.prompt_client import prompt_client as prompt_client
76
from application.database import db
87

8+
if TYPE_CHECKING:
9+
from application.prompt_client import prompt_client as prompt_client
10+
911
# abstract class/interface that shows how to import a project that is not cre or its core resources
1012

1113

@@ -125,7 +127,7 @@ class ParserInterface(object):
125127

126128
def parse(
127129
database: db.Node_collection,
128-
prompt_client: Optional[prompt_client.PromptHandler],
130+
prompt_client: Optional["prompt_client.PromptHandler"],
129131
) -> ParseResult:
130132
"""
131133
Parses the resources of a project,

0 commit comments

Comments
 (0)