diff --git a/src/test/test_flybase_stocks.py b/src/test/test_flybase_stocks.py index 47979d4..2501edf 100644 --- a/src/test/test_flybase_stocks.py +++ b/src/test/test_flybase_stocks.py @@ -292,12 +292,43 @@ def test_bloomington_stock_number_deep_links(self): assert _stock_number_url("Bloomington Drosophila Stock Center", "6565") == \ "https://bdsc.indiana.edu/stocks/6565" + def test_vdrc_stock_number_drops_the_v(self): + assert _stock_number_url("Vienna Drosophila Resource Center", "v49835") == \ + "https://shop.vbc.ac.at/vdrc_store/49835.html" + + def test_kyoto_stock_number_deep_links(self): + assert _stock_number_url("Kyoto Stock Center", "103972") == ( + "https://kyotofly.kit.jp/cgi-bin/stocks/" + "search_res_det.cgi?DB_NUM=1&DG_NUM=103972") + + @pytest.mark.parametrize("number, expected", [ + # One detail view per NIG stock type, chosen by the number's shape. + ("10052R-1", "rnaiDetailAction.do?input=sr&stockId=10052R-1"), + ("15513-3R-3", "rnaiDetailAction.do?input=sr&stockId=15513-3R-3"), + ("2381Ra-3", "rnaiDetailAction.do?input=sr&stockId=2381Ra-3"), + ("31794R-C-2", "rnaiDetailAction.do?input=sr&stockId=31794R-C-2"), + ("M2L-2508", "koDetailAction.do?input=sr&stockId=M2L-2508"), + ("2LG-1995", "grnaDetailAction.do?input=sr&stockId=2LG-1995"), + ("2RG-0196", "grnaDetailAction.do?input=sr&stockId=2RG-0196"), + ("HMJ02061", "tripDetailAction.do?input=list&tripNo=HMJ02061"), + ("GL00001", "tripDetailAction.do?input=list&tripNo=GL00001"), + ]) + def test_nig_stock_number_picks_the_right_detail_view(self, number, expected): + assert _stock_number_url("National Institute of Genetics Fly Stocks", number) == \ + "https://shigen.nig.ac.jp/fly/nigfly/" + expected + + def test_nig_number_of_an_unmapped_shape_gets_no_link(self): + # The six SHA##### stocks sit in none of the NIG detail views. A centre + # in the table must not fall through to the chado order_url guess. + assert _stock_number_url( + "National Institute of Genetics Fly Stocks", "SHA00054") is None + def test_unknown_collection_has_no_stock_number_link(self, monkeypatch): import vfbquery.flybase_stocks as fbs monkeypatch.setattr(fbs, "collection_links", - lambda: {"Kyoto Stock Center": { - "order_url": "https://kyotofly.kit.jp/cgi-bin/stocks/index.cgi"}}) - assert _stock_number_url("Kyoto Stock Center", "103972") is None + lambda: {"Korea Drosophila Resource Center": { + "order_url": "http://kdrc.kr/index.php"}}) + assert _stock_number_url("Korea Drosophila Resource Center", "13276") is None def test_order_url_ending_in_equals_takes_the_stock_number(self, monkeypatch): import vfbquery.flybase_stocks as fbs diff --git a/src/vfbquery/vfb_queries.py b/src/vfbquery/vfb_queries.py index 452ba87..7ab269a 100644 --- a/src/vfbquery/vfb_queries.py +++ b/src/vfbquery/vfb_queries.py @@ -5156,14 +5156,51 @@ def get_upstream_class_connectivity(short_form: str, return_dataframe=True, limi # Stock centres whose own catalogue has a stable per-stock URL that can be # built from the FlyBase stock number. Chado carries every centre's homepage -# and order URL in stockcollectionprop, but not this one: Bloomington's -# order_url is its batch-order cart, so the per-stock pattern has to live here. -# FlyBase's own stock report deep-links the stock number for exactly two -# centres -- Bloomington and FlyORF -- and renders it as plain text for the -# other five, which only offer a search box (checked against one stock per -# collection, 2026-09-03). Mirror that rather than invent URL patterns. +# and order URL in stockcollectionprop, but never a per-stock pattern, so +# those live here, keyed by collection and matched on the shape of the stock +# number (NIG has one detail view per stock type, and the number says which). +# +# FlyBase's own stock report deep-links only Bloomington and FlyORF; VDRC, NIG +# and Kyoto below go further than FlyBase does, so each pattern was checked +# against 20 stock numbers drawn at random from that collection in chado, +# requiring HTTP 200 *and* the stock number present in the page returned +# (60/60, 2026-09-07). The self-reference check is the real test: every one of +# these sites answers 200 with an empty shell for a number it does not hold. +# +# Deliberately not linked, because they offer only a search box or +# species-level pages: Korea Drosophila Resource Center (7,020 stocks), the +# National Drosophila Species Stock Center (2,059), and NIG's six SHA##### +# stocks, which appear in none of the NIG detail views. +_NIG_FLY = "https://shigen.nig.ac.jp/fly/nigfly/" + _STOCK_NUMBER_URL = { - "Bloomington Drosophila Stock Center": "https://bdsc.indiana.edu/stocks/{number}", + "Bloomington Drosophila Stock Center": [ + (re.compile(r"^(?P.+)$"), "https://bdsc.indiana.edu/stocks/{n}"), + ], + # Uniformly v in chado (26,479 of 26,479); the shop drops the v. + "Vienna Drosophila Resource Center": [ + (re.compile(r"^v(?P\d+)$"), + "https://shop.vbc.ac.at/vdrc_store/{n}.html"), + ], + "Kyoto Stock Center": [ + (re.compile(r"^(?P\d+)$"), + "https://kyotofly.kit.jp/cgi-bin/stocks/" + "search_res_det.cgi?DB_NUM=1&DG_NUM={n}"), + ], + # NIG splits its catalogue by stock type, one detail view each. RNAi + # numbers carry an R (10052R-1, 15513-3R-3, 2381Ra-3, 31794R-C-2), knockouts + # are ML-, gRNA lines LG-/RG-, and the TRiP lines it redistributes + # are looked up by tripNo rather than stockId. + "National Institute of Genetics Fly Stocks": [ + (re.compile(r"^(?P\d+(?:-\d+)?R[ab]?(?:-[CN])?-\d+)$"), + _NIG_FLY + "rnaiDetailAction.do?input=sr&stockId={n}"), + (re.compile(r"^(?PM\dL-\d+)$"), + _NIG_FLY + "koDetailAction.do?input=sr&stockId={n}"), + (re.compile(r"^(?P\d[LR]G-\d+)$"), + _NIG_FLY + "grnaDetailAction.do?input=sr&stockId={n}"), + (re.compile(r"^(?P(?:HM[JSC]|GL|JF)\d+)$"), + _NIG_FLY + "tripDetailAction.do?input=list&tripNo={n}"), + ], } _FLYBASE_ID_RE = re.compile(r"^FB[a-z]{2}\d+$") @@ -5196,20 +5233,29 @@ def _flybase_report_url(fb_id): def _stock_number_url(collection, number): """URL for the stock centre's own catalogue entry, or None. - Bloomington comes from the table above. Every other centre is derived from - chado: FlyORF's ``order_url`` is a per-line query prefix ending in ``=``, - so the stock number appends cleanly; the rest are homepages or batch-order - forms where appending a number would produce a dead link. + Centres in :data:`_STOCK_NUMBER_URL` are matched on the shape of the stock + number; a centre listed there but whose number matches none of its patterns + gets no link rather than falling through to a guess. Everything else comes + from chado: FlyORF's ``order_url`` is a per-line query prefix ending in + ``=``, so the stock number appends cleanly; the rest are homepages or + batch-order forms where appending a number would produce a dead link. """ if not collection or not number: return None - pattern = _STOCK_NUMBER_URL.get(collection) - if pattern: - return pattern.format(number=quote(str(number), safe="")) + number = str(number) + patterns = _STOCK_NUMBER_URL.get(collection) + if patterns is not None: + for pattern, template in patterns: + match = pattern.match(number) + if match: + return template.format(**{ + key: quote(value, safe="") + for key, value in match.groupdict().items()}) + return None from .flybase_stocks import collection_links order_url = collection_links().get(collection, {}).get("order_url") or "" if order_url.endswith("="): - return order_url + quote(str(number), safe="") + return order_url + quote(number, safe="") return None