diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index b112fd0f2..f075c5ea7 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -20,6 +20,7 @@ * [gget cosmic](en/cosmic.md) * [gget diamond](en/diamond.md) * [gget elm](en/elm.md) +* [gget encode](en/encode.md) * [gget enrichr](en/enrichr.md) * [gget g2p](en/g2p.md) * [gget gpt](en/gpt.md) diff --git a/docs/src/en/encode.md b/docs/src/en/encode.md new file mode 100644 index 000000000..f82aa5bac --- /dev/null +++ b/docs/src/en/encode.md @@ -0,0 +1,97 @@ +[ View page source on GitHub ](https://github.com/scverse/gget/blob/main/docs/src/en/encode.md) + +> Python arguments are equivalent to long-option arguments (`--arg`), unless otherwise specified. Flags are True/False arguments in Python. The manual for any gget tool can be called from the command-line using the `-h` `--help` flag. +# gget encode 🧫 +Query and download data from the [ENCODE project](https://www.encodeproject.org/). +`gget encode` has two modes, chosen automatically from the input: +- If the input is an ENCODE **accession** (e.g. `ENCSR000AKS` for an experiment or `ENCFF000BXK` for a file), the matching object is fetched and its file(s) are returned (with download URLs). Use `--download` to fetch the files. +- Otherwise, the input is used as a **free-text search** against the ENCODE search endpoint and the matching objects of the given `--type` are returned. + +Return format: JSON (command-line) or data frame/CSV (Python). + +**Positional argument** +`search_term` +ENCODE accession (e.g. `ENCSR000AKS` or `ENCFF000BXK`) or a free-text search term. + +**Optional arguments** +`-t` `--type` +ENCODE object type for free-text searches, e.g. `Experiment`, `File`, `Biosample`. Default: `Experiment`. + +`-l` `--limit` +Maximum number of results for free-text searches. Default: 10. + +`-a` `--assembly` +Only return files for this genome assembly, e.g. `GRCh38`. Default: None. + +`-ff` `--file_format` +Only return files of this format, e.g. `bam`, `fastq`, `bigWig`. Default: None. + +`-ot` `--output_type` +Only return files of this output type, e.g. `alignments`. Default: None. + +`-od` `--out_dir` +Directory to download files into (used with `--download`). Default: current directory. + +`-o` `--out` +Path to the file the results table will be saved in, e.g. path/to/directory/results.csv (or .json). Default: Standard out. +Python: `save=True` will save the output in the current working directory. + +**Flags** +`-d` `--download` +Download the returned files into the directory given by `--out_dir`. Only supported for ENCODE accessions (experiments/files). + +`-csv` `--csv` +Command-line only. Returns results in CSV format. +Python: Use `json=True` to return output in JSON format. + +`-q` `--quiet` +Command-line only. Prevents progress information from being displayed. +Python: Use `verbose=False` to prevent progress information from being displayed. + +### Examples +**Search for experiments:** +```bash +gget encode "CTCF K562" +``` +```python +# Python +gget.encode("CTCF K562") +``` +→ Returns a table of ENCODE experiments matching the search term. + +| accession | assay_title | biosample_summary | target | description | status | lab | +| --- | --- | --- | --- | --- | --- | --- | +| ENCSR... | TF ChIP-seq | Homo sapiens K562 | CTCF | ... | released | ... | + +

+**List the files of an experiment (filtered to GRCh38 BAMs):** +```bash +gget encode ENCSR000AKS --assembly GRCh38 --file_format bam +``` +```python +# Python +gget.encode("ENCSR000AKS", assembly="GRCh38", file_format="bam") +``` +→ Returns a table of files (with download URLs) for the experiment. + +| file_accession | file_format | output_type | assembly | file_size | status | url | +| --- | --- | --- | --- | --- | --- | --- | +| ENCFF... | bam | alignments | GRCh38 | 123456 | released | https://www.encodeproject.org/files/ENCFF.../@@download/ENCFF....bam | + +

+**Download the files into a directory:** +```bash +gget encode ENCSR000AKS --file_format bam --download --out_dir ./encode_data +``` +```python +# Python +gget.encode("ENCSR000AKS", file_format="bam", download=True, out_dir="./encode_data") +``` +→ Downloads the matching files into `./encode_data`. + +# References +If you use `gget encode` in a publication, please cite the following articles: + +- Luebbert, L., & Pachter, L. (2023). Efficient querying of genomic reference databases with gget. Bioinformatics. [https://doi.org/10.1093/bioinformatics/btac836](https://doi.org/10.1093/bioinformatics/btac836) + +- ENCODE Project Consortium. (2012). An integrated encyclopedia of DNA elements in the human genome. Nature. [https://doi.org/10.1038/nature11247](https://doi.org/10.1038/nature11247) diff --git a/docs/src/en/updates.md b/docs/src/en/updates.md index 89be22281..85e8079e7 100644 --- a/docs/src/en/updates.md +++ b/docs/src/en/updates.md @@ -5,6 +5,7 @@ #### *gget* officially became part of [*scverse*](https://scverse.org/) on June 9, 2026. 🥳🥳🥳 **Version ≥ 0.30.9** (XXX XX, 2026): +- [`gget encode`](encode.md): **New module** to query and download data from the [ENCODE project](https://www.encodeproject.org/). Pass an ENCODE accession (e.g. `ENCSR000AKS` experiment or `ENCFF000BXK` file) to list its file(s) with download URLs — optionally filtered by `assembly`, `file_format`, and `output_type`, and downloaded with `download=True` — or pass a free-text term to search ENCODE objects of a given `type`. Available in the Python API and on the command line. Resolves [issue 151](https://github.com/scverse/gget/issues/151). **Version ≥ 0.30.8** (Jun 28, 2026): - [`gget g2p`](g2p.md): Either `gene` or `--uniprot_id` is now sufficient — whichever is missing is resolved via UniProt and cached. Gene→UniProt picks the canonical reviewed human Swiss-Prot entry; the resolution and its limitations are logged. The canonical pair is **always** prepended to the result as `gene_name` / `uniprot_id` columns (and stored on `df.attrs`), so the output schema is invariant regardless of input mode. Existing call sites continue to work. diff --git a/gget/__init__.py b/gget/__init__.py index f56cbcddc..956e9756f 100644 --- a/gget/__init__.py +++ b/gget/__init__.py @@ -14,6 +14,7 @@ from .gget_cosmic import cosmic from .gget_diamond import diamond from .gget_elm import elm +from .gget_encode import encode from .gget_enrichr import enrichr from .gget_g2p import g2p from .gget_gpt import gpt diff --git a/gget/constants.py b/gget/constants.py index 38f90119f..51f95c0c6 100644 --- a/gget/constants.py +++ b/gget/constants.py @@ -7,6 +7,9 @@ # strategy avoid hanging indefinitely on slow upstreams. DEFAULT_REQUESTS_TIMEOUT = (10, 60) +# ENCODE project REST API for gget encode +ENCODE_URL = "https://www.encodeproject.org" + # Ensembl REST API server for gget seq and info ENSEMBL_REST_API = "http://rest.ensembl.org/" ENSEMBL_FTP_URL = "http://ftp.ensembl.org/pub/" diff --git a/gget/gget_encode.py b/gget/gget_encode.py new file mode 100644 index 000000000..bc8bfddce --- /dev/null +++ b/gget/gget_encode.py @@ -0,0 +1,265 @@ +from __future__ import annotations + +import json as json_package +import os +import re +from typing import Any, Literal, overload + +import pandas as pd +import requests + +from .constants import DEFAULT_REQUESTS_TIMEOUT, ENCODE_URL +from .utils import set_up_logger + +logger = set_up_logger() + +# Columns returned for ENCODE file listings (experiment/file accessions) +_FILE_COLUMNS = [ + "file_accession", + "file_format", + "output_type", + "assembly", + "file_size", + "status", + "url", +] +# Columns returned for free-text searches +_SEARCH_COLUMNS = [ + "accession", + "assay_title", + "biosample_summary", + "target", + "description", + "status", + "lab", +] + +# ENCODE accessions look like ENCSR000AKS (experiment), ENCFF000BXK (file), etc. +_ACCESSION_RE = re.compile(r"^ENC[A-Z]{2}[0-9A-Za-z]+$") + + +def _is_encode_accession(term: str) -> bool: + """Return True if 'term' looks like an ENCODE accession (e.g. ENCSR000AKS).""" + return bool(_ACCESSION_RE.match(term.strip())) + + +def _encode_get(path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + """GET a JSON object from the ENCODE REST API.""" + url = f"{ENCODE_URL}{path}" + try: + response = requests.get( + url, + params=params, + headers={"Accept": "application/json"}, + timeout=DEFAULT_REQUESTS_TIMEOUT, + ) + except requests.exceptions.RequestException as exc: + raise RuntimeError(f"The ENCODE server request failed: {exc}") from exc + + if response.status_code == 404: + raise ValueError(f"ENCODE returned 404 (not found) for '{path}'. Please double-check the accession/term.") + if not response.ok: + raise RuntimeError( + f"The ENCODE server returned error status code {response.status_code}. Please try again later." + ) + + return response.json() + + +def _files_to_df( + files: list[dict[str, Any]], + assembly: str | None = None, + file_format: str | None = None, + output_type: str | None = None, +) -> pd.DataFrame: + """Build a data frame of ENCODE files, applying optional filters.""" + rows = [] + for f in files: + if assembly is not None and f.get("assembly") != assembly: + continue + if file_format is not None and f.get("file_format") != file_format: + continue + if output_type is not None and f.get("output_type") != output_type: + continue + href = f.get("href") + rows.append( + { + "file_accession": f.get("accession"), + "file_format": f.get("file_format"), + "output_type": f.get("output_type"), + "assembly": f.get("assembly"), + "file_size": f.get("file_size"), + "status": f.get("status"), + "url": f"{ENCODE_URL}{href}" if href else None, + } + ) + return pd.DataFrame(rows, columns=_FILE_COLUMNS) + + +def _search_row(g: dict[str, Any]) -> dict[str, Any]: + """Flatten one ENCODE search result into a row of scalar values.""" + target = g.get("target") + target_label = target.get("label") if isinstance(target, dict) else target + lab = g.get("lab") + lab_title = lab.get("title") if isinstance(lab, dict) else lab + return { + "accession": g.get("accession"), + "assay_title": g.get("assay_title"), + "biosample_summary": g.get("biosample_summary"), + "target": target_label, + "description": g.get("description"), + "status": g.get("status"), + "lab": lab_title, + } + + +def _download_files(df: pd.DataFrame, out_dir: str, verbose: bool = True) -> None: + """Download every file in the 'url' column of 'df' into out_dir.""" + urls = [u for u in df.get("url", []) if u] + if len(urls) == 0: + logger.warning("No downloadable files found for the given query.") + return + + os.makedirs(out_dir, exist_ok=True) + for url in urls: + filename = url.split("/")[-1] + dest = os.path.join(out_dir, filename) + if verbose: + logger.info(f"Downloading {filename}...") + try: + with requests.get(url, stream=True, timeout=DEFAULT_REQUESTS_TIMEOUT) as r: + r.raise_for_status() + with open(dest, "wb") as fh: + for chunk in r.iter_content(chunk_size=8192): + fh.write(chunk) + except requests.exceptions.RequestException as exc: + logger.error(f"Failed to download {filename}: {exc}") + + +@overload +def encode( + search_term: str, + type: str = "Experiment", + limit: int = 10, + assembly: str | None = None, + file_format: str | None = None, + output_type: str | None = None, + download: bool = False, + out_dir: str = ".", + save: bool = False, + verbose: bool = True, + *, + json: Literal[True], +) -> list[dict[str, Any]] | None: ... + + +@overload +def encode( + search_term: str, + type: str = "Experiment", + limit: int = 10, + assembly: str | None = None, + file_format: str | None = None, + output_type: str | None = None, + download: bool = False, + out_dir: str = ".", + save: bool = False, + verbose: bool = True, + json: Literal[False] = False, +) -> pd.DataFrame | None: ... + + +def encode( + search_term: str, + type: str = "Experiment", + limit: int = 10, + assembly: str | None = None, + file_format: str | None = None, + output_type: str | None = None, + download: bool = False, + out_dir: str = ".", + save: bool = False, + verbose: bool = True, + json: bool = False, +) -> pd.DataFrame | list[dict[str, Any]] | None: + """Query and download data from the ENCODE project (https://www.encodeproject.org/). + + Two modes, chosen automatically from 'search_term': + - If 'search_term' is an ENCODE accession (e.g. "ENCSR000AKS" for an + experiment or "ENCFF000BXK" for a file), the matching object is fetched + and its file(s) are returned (with download URLs). + - Otherwise, 'search_term' is used as a free-text query against the ENCODE + search endpoint and the matching objects of the given 'type' are returned. + + Args: + - search_term ENCODE accession (experiment/file/...) or free-text search term. + - type ENCODE object type for free-text searches, e.g. "Experiment", + "File", "Biosample". Default: "Experiment". + - limit Maximum number of results for free-text searches. Default: 10. + - assembly Only return files for this genome assembly, e.g. "GRCh38". Default: None. + - file_format Only return files of this format, e.g. "bam", "fastq", "bigWig". Default: None. + - output_type Only return files of this output type, e.g. "alignments". Default: None. + - download If True, download the returned files into 'out_dir'. Default: False. + - out_dir Directory to download files into. Default: "." (current directory). + - save If True, save the results table as csv/json in the working directory. Default: False. + - verbose True/False whether to print progress information. Default: True. + - json If True, returns results in json format instead of data frame. Default: False. + + Returns a data frame (or list of dicts if json=True) of files or search results. + Returns None if no results are found. + """ + if search_term is None or str(search_term).strip() == "": + raise ValueError("Please provide a search term or ENCODE accession in 'search_term'.") + + term = str(search_term).strip() + source = "encode_search" + + if _is_encode_accession(term): + if verbose: + logger.info(f"Fetching ENCODE object {term}...") + obj = _encode_get(f"/{term}/", params={"format": "json"}) + obj_types = obj.get("@type", []) + + if "File" in obj_types: + source = "encode_files" + results_df = _files_to_df([obj], assembly, file_format, output_type) + elif "files" in obj: + source = "encode_files" + results_df = _files_to_df(obj.get("files", []), assembly, file_format, output_type) + else: + # Generic object: return its top-level scalar metadata as a single row + source = "encode_metadata" + scalar = {k: v for k, v in obj.items() if isinstance(v, (str, int, float, bool)) or v is None} + results_df = pd.DataFrame([scalar]) + + if download: + _download_files(results_df, out_dir, verbose) + else: + if verbose: + logger.info(f"Searching ENCODE for '{term}' (type={type})...") + data = _encode_get( + "/search/", + params={"type": type, "searchTerm": term, "limit": limit, "format": "json"}, + ) + rows = [_search_row(g) for g in data.get("@graph", [])] + results_df = pd.DataFrame(rows, columns=_SEARCH_COLUMNS) + if download: + logger.warning( + "'download' is only supported for ENCODE accessions (experiments/files), not free-text searches." + ) + + if len(results_df) == 0: + logger.warning(f"No ENCODE results found for '{term}'.") + return None + + if json: + results_dict = json_package.loads(results_df.to_json(orient="records")) + if save: + with open(f"gget_{source}_results.json", "w", encoding="utf-8") as f: + json_package.dump(results_dict, f, ensure_ascii=False, indent=4) + return results_dict + + if save: + results_df.to_csv(f"gget_{source}_results.csv", index=False) + + return results_df diff --git a/gget/main.py b/gget/main.py index 7a2944b09..c7d0e6f12 100644 --- a/gget/main.py +++ b/gget/main.py @@ -29,6 +29,7 @@ from .gget_cosmic import cosmic # noqa: E402 from .gget_diamond import diamond # noqa: E402 from .gget_elm import elm # noqa: E402 +from .gget_encode import encode # noqa: E402 from .gget_enrichr import enrichr # noqa: E402 from .gget_g2p import g2p # noqa: E402 from .gget_gpt import gpt # noqa: E402 @@ -1002,6 +1003,104 @@ def main() -> None: help="DEPRECATED - json is now the default output format (convert to csv using flag [--csv]).", ) + ## gget encode subparser + encode_desc = "Query and download data from the ENCODE project (https://www.encodeproject.org/)." + parser_encode = parent_subparsers.add_parser( + "encode", + parents=[parent], + description=encode_desc, + help=encode_desc, + add_help=True, + formatter_class=CustomHelpFormatter, + ) + parser_encode.add_argument( + "search_term", + type=str, + help="ENCODE accession (e.g. ENCSR000AKS or ENCFF000BXK) or a free-text search term.", + ) + parser_encode.add_argument( + "-t", + "--type", + type=str, + default="Experiment", + required=False, + help="ENCODE object type for free-text searches, e.g. 'Experiment', 'File', 'Biosample'. Default: 'Experiment'.", + ) + parser_encode.add_argument( + "-l", + "--limit", + type=int, + default=10, + required=False, + help="Maximum number of results for free-text searches. Default: 10.", + ) + parser_encode.add_argument( + "-a", + "--assembly", + type=str, + default=None, + required=False, + help="Only return files for this genome assembly, e.g. 'GRCh38'. Default: None.", + ) + parser_encode.add_argument( + "-ff", + "--file_format", + type=str, + default=None, + required=False, + help="Only return files of this format, e.g. 'bam', 'fastq', 'bigWig'. Default: None.", + ) + parser_encode.add_argument( + "-ot", + "--output_type", + type=str, + default=None, + required=False, + help="Only return files of this output type, e.g. 'alignments'. Default: None.", + ) + parser_encode.add_argument( + "-d", + "--download", + default=False, + action="store_true", + required=False, + help="Download the returned files into the directory given by --out_dir.", + ) + parser_encode.add_argument( + "-od", + "--out_dir", + type=str, + default=".", + required=False, + help="Directory to download files into. Default: current directory.", + ) + parser_encode.add_argument( + "-csv", + "--csv", + default=True, + action="store_false", + required=False, + help="Returns results in csv format instead of json.", + ) + parser_encode.add_argument( + "-o", + "--out", + type=str, + required=False, + help=( + "Path to the file the results table will be saved in, e.g. path/to/directory/results.csv (or .json).\n" + "Default: Standard out." + ), + ) + parser_encode.add_argument( + "-q", + "--quiet", + default=True, + action="store_false", + required=False, + help="Does not print progress information.", + ) + ## gget enrichr subparser enrichr_desc = "Perform an enrichment analysis on a list of genes using Enrichr." parser_enrichr = parent_subparsers.add_parser( @@ -2956,6 +3055,7 @@ def main() -> None: "muscle": parser_muscle, "blast": parser_blast, "blat": parser_blat, + "encode": parser_encode, "enrichr": parser_enrichr, "archs4": parser_archs4, "setup": parser_setup, @@ -3502,6 +3602,43 @@ def main() -> None: if not args.out and args.csv: print(json.dumps(gget_results, ensure_ascii=False, indent=4)) + ## encode return + if args.command == "encode": + encode_results = encode( + search_term=args.search_term, + type=args.type, + limit=args.limit, + assembly=args.assembly, + file_format=args.file_format, + output_type=args.output_type, + download=args.download, + out_dir=args.out_dir, + json=args.csv, + verbose=args.quiet, + ) + + # Check if the function returned something + if encode_results is not None: + # Save results if args.out specified + if args.out and not args.csv: + directory = "/".join(args.out.split("/")[:-1]) + if directory != "": + os.makedirs(directory, exist_ok=True) + encode_results.to_csv(args.out, index=False) + + if args.out and args.csv: + directory = "/".join(args.out.split("/")[:-1]) + if directory != "": + os.makedirs(directory, exist_ok=True) + with open(args.out, "w", encoding="utf-8") as f: + json.dump(encode_results, f, ensure_ascii=False, indent=4) + + # Print results if no directory specified + if not args.out and not args.csv: + encode_results.to_csv(sys.stdout, index=False) + if not args.out and args.csv: + print(json.dumps(encode_results, ensure_ascii=False, indent=4)) + ## enrichr return if args.command == "enrichr": # Handle deprecated flags for backwards compatibility diff --git a/tests/fixtures/test_encode.json b/tests/fixtures/test_encode.json new file mode 100644 index 000000000..f06250121 --- /dev/null +++ b/tests/fixtures/test_encode.json @@ -0,0 +1,10 @@ +{ + "test_encode_no_term": { + "type": "error", + "args": { + "search_term": "" + }, + "expected_result": "ValueError", + "expected_msg": "Please provide a search term or ENCODE accession in 'search_term'." + } +} diff --git a/tests/test_encode.py b/tests/test_encode.py new file mode 100644 index 000000000..354408655 --- /dev/null +++ b/tests/test_encode.py @@ -0,0 +1,234 @@ +import json +import os +import tempfile +import unittest +from unittest.mock import MagicMock, patch + +import gget.gget_encode as gget_encode +import pandas as pd +import requests +from gget.gget_encode import _files_to_df, _is_encode_accession, _search_row, encode + +from .from_json import from_json + +with open("./tests/fixtures/test_encode.json") as json_file: + encode_dict = json.load(json_file) + + +class TestEncode(unittest.TestCase, metaclass=from_json(encode_dict, encode)): + pass # tests loaded from json + + +class _FakeResponse: + """Minimal stand-in for a requests.Response used to test ENCODE parsing offline.""" + + def __init__(self, payload, ok=True, status_code=200): + self._payload = payload + self.ok = ok + self.status_code = status_code + + def json(self): + return self._payload + + +_EXPERIMENT_PAYLOAD = { + "@type": ["Experiment", "Dataset", "Item"], + "accession": "ENCSR000AKS", + "files": [ + { + "accession": "ENCFF000BXK", + "file_format": "bam", + "output_type": "alignments", + "assembly": "GRCh38", + "file_size": 123, + "status": "released", + "href": "/files/ENCFF000BXK/@@download/ENCFF000BXK.bam", + }, + { + "accession": "ENCFF000BXM", + "file_format": "bigBed", + "output_type": "peaks", + "assembly": "hg19", + "file_size": 456, + "status": "released", + "href": "/files/ENCFF000BXM/@@download/ENCFF000BXM.bigBed", + }, + ], +} + +_FILE_PAYLOAD = { + "@type": ["File", "Item"], + "accession": "ENCFF000BXK", + "file_format": "bam", + "output_type": "alignments", + "assembly": "GRCh38", + "file_size": 123, + "status": "released", + "href": "/files/ENCFF000BXK/@@download/ENCFF000BXK.bam", +} + +_SEARCH_PAYLOAD = { + "@graph": [ + { + "accession": "ENCSR111AAA", + "assay_title": "TF ChIP-seq", + "biosample_summary": "Homo sapiens K562", + "target": {"label": "CTCF"}, + "description": "desc", + "status": "released", + "lab": {"title": "Some Lab"}, + } + ] +} + + +class TestEncodeHelpers(unittest.TestCase): + """Network-free tests of the ENCODE helpers (issue #151).""" + + def test_is_accession(self): + self.assertTrue(_is_encode_accession("ENCSR000AKS")) + self.assertTrue(_is_encode_accession("ENCFF000BXK")) + self.assertFalse(_is_encode_accession("CTCF K562")) + self.assertFalse(_is_encode_accession("chip-seq")) + + def test_files_to_df_and_filter(self): + df = _files_to_df(_EXPERIMENT_PAYLOAD["files"]) + self.assertEqual(list(df.columns), gget_encode._FILE_COLUMNS) + self.assertEqual(df.shape[0], 2) + self.assertEqual( + df.iloc[0]["url"], "https://www.encodeproject.org/files/ENCFF000BXK/@@download/ENCFF000BXK.bam" + ) + # Filter by assembly + df2 = _files_to_df(_EXPERIMENT_PAYLOAD["files"], assembly="GRCh38") + self.assertEqual(df2.shape[0], 1) + self.assertEqual(df2.iloc[0]["file_accession"], "ENCFF000BXK") + # Filter by file_format + df3 = _files_to_df(_EXPERIMENT_PAYLOAD["files"], file_format="bigBed") + self.assertEqual(df3.shape[0], 1) + self.assertEqual(df3.iloc[0]["file_format"], "bigBed") + + def test_search_row(self): + row = _search_row(_SEARCH_PAYLOAD["@graph"][0]) + self.assertEqual(row["accession"], "ENCSR111AAA") + self.assertEqual(row["target"], "CTCF") + self.assertEqual(row["lab"], "Some Lab") + + @patch.object(gget_encode.requests, "get") + def test_experiment_accession(self, mock_get): + mock_get.return_value = _FakeResponse(_EXPERIMENT_PAYLOAD) + df = encode("ENCSR000AKS", verbose=False) + self.assertEqual(list(df.columns), gget_encode._FILE_COLUMNS) + self.assertEqual(df.shape[0], 2) + + @patch.object(gget_encode.requests, "get") + def test_file_accession(self, mock_get): + mock_get.return_value = _FakeResponse(_FILE_PAYLOAD) + df = encode("ENCFF000BXK", verbose=False) + self.assertEqual(df.shape[0], 1) + self.assertEqual(df.iloc[0]["file_accession"], "ENCFF000BXK") + + @patch.object(gget_encode.requests, "get") + def test_search_mode_json(self, mock_get): + mock_get.return_value = _FakeResponse(_SEARCH_PAYLOAD) + result = encode("CTCF K562", json=True, verbose=False) + self.assertIsInstance(result, list) + self.assertEqual(result[0]["accession"], "ENCSR111AAA") + self.assertEqual(result[0]["target"], "CTCF") + + @patch.object(gget_encode.requests, "get") + def test_no_results_returns_none(self, mock_get): + mock_get.return_value = _FakeResponse({"@graph": []}) + self.assertIsNone(encode("nonexistent term xyz", verbose=False)) + + @patch.object(gget_encode.requests, "get") + def test_http_error_raises(self, mock_get): + mock_get.return_value = _FakeResponse({}, ok=False, status_code=500) + with self.assertRaises(RuntimeError): + encode("ENCSR000AKS", verbose=False) + + def test_empty_search_term_raises(self): + # Covers the empty/None search_term ValueError branch. + with self.assertRaises(ValueError): + encode(" ", verbose=False) + + def test_files_to_df_output_type_filter(self): + # Covers the output_type filter branch. + df = _files_to_df(_EXPERIMENT_PAYLOAD["files"], output_type="peaks") + self.assertEqual(df.shape[0], 1) + self.assertEqual(df.iloc[0]["output_type"], "peaks") + + @patch.object(gget_encode.requests, "get") + def test_encode_get_request_exception(self, mock_get): + # Covers the requests.RequestException -> RuntimeError branch in _encode_get. + mock_get.side_effect = requests.exceptions.ConnectionError("no network") + with self.assertRaises(RuntimeError): + encode("ENCSR000AKS", verbose=False) + + @patch.object(gget_encode.requests, "get") + def test_encode_get_404(self, mock_get): + # Covers the 404 -> ValueError branch in _encode_get. + mock_get.return_value = _FakeResponse({}, ok=False, status_code=404) + with self.assertRaises(ValueError): + encode("ENCSR000AKS", verbose=False) + + @patch.object(gget_encode.requests, "get") + def test_generic_object_metadata(self, mock_get): + # Covers the generic-object (non-File, no 'files') metadata branch. + mock_get.return_value = _FakeResponse( + {"@type": ["Biosample", "Item"], "accession": "ENCBS000AAA", "status": "released", "nested": {"x": 1}} + ) + df = encode("ENCBS000AAA", verbose=False) + self.assertEqual(df.iloc[0]["accession"], "ENCBS000AAA") + + @patch.object(gget_encode, "_download_files") + @patch.object(gget_encode.requests, "get") + def test_accession_download_and_verbose(self, mock_get, mock_dl): + # Covers the verbose-logging line and the download branch (accession path). + mock_get.return_value = _FakeResponse(_EXPERIMENT_PAYLOAD) + encode("ENCSR000AKS", download=True, verbose=True) + self.assertTrue(mock_dl.called) + + @patch.object(gget_encode.requests, "get") + def test_search_verbose_and_download_warning(self, mock_get): + # Covers the search-path verbose line and the download-not-supported warning. + mock_get.return_value = _FakeResponse(_SEARCH_PAYLOAD) + result = encode("CTCF K562", download=True, verbose=True) + self.assertEqual(result.iloc[0]["accession"], "ENCSR111AAA") + + @patch.object(gget_encode.requests, "get") + def test_save_csv_and_json(self, mock_get): + # Covers the save-to-CSV and json+save branches. + mock_get.return_value = _FakeResponse(_EXPERIMENT_PAYLOAD) + with tempfile.TemporaryDirectory() as tmp: + cwd = os.getcwd() + os.chdir(tmp) + try: + encode("ENCSR000AKS", save=True, verbose=False) + self.assertTrue(any(f.endswith(".csv") for f in os.listdir("."))) + encode("ENCSR000AKS", save=True, json=True, verbose=False) + self.assertTrue(any(f.endswith(".json") for f in os.listdir("."))) + finally: + os.chdir(cwd) + + def test_download_files_no_urls(self): + # Covers the "no downloadable files" early-return branch. + gget_encode._download_files(pd.DataFrame({"url": []}), "unused_dir") + + @patch.object(gget_encode.requests, "get") + def test_download_files_writes_and_handles_error(self, mock_get): + # Covers the streaming-download body and the per-file error branch. + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.iter_content.return_value = [b"chunk1", b"chunk2"] + mock_get.return_value.__enter__.return_value = resp + df = pd.DataFrame({"url": ["https://www.encodeproject.org/files/ENCFF000BXK/@@download/ENCFF000BXK.bam"]}) + with tempfile.TemporaryDirectory() as tmp: + gget_encode._download_files(df, tmp, verbose=True) + self.assertTrue(os.path.exists(os.path.join(tmp, "ENCFF000BXK.bam"))) + # Error branch: requests.get raises -> logged, not raised + mock_get.side_effect = requests.exceptions.ConnectionError("boom") + gget_encode._download_files(df, tmp, verbose=False) + + +if __name__ == "__main__": + unittest.main()