From 6d0e5b5711dbae5c24597fe5c661baccb31d2c5e Mon Sep 17 00:00:00 2001 From: Sean Cheah Date: Tue, 19 May 2026 14:57:15 -0700 Subject: [PATCH 1/6] Refactor mem and time scaling --- runscripts/nextflow/config.template | 157 ++++++++-------------------- 1 file changed, 44 insertions(+), 113 deletions(-) diff --git a/runscripts/nextflow/config.template b/runscripts/nextflow/config.template index 60f4fde4c..3913ff3bd 100644 --- a/runscripts/nextflow/config.template +++ b/runscripts/nextflow/config.template @@ -26,7 +26,8 @@ params { proc.waitFor() return proc.text.trim() } catch (Exception e) { - return '' + System.err.println("WARNING: Failed to read gcloud compute/region: ${e.message}") + return 'GCLOUD_CONFIG_ERROR' } }() google_project = { @@ -35,7 +36,8 @@ params { proc.waitFor() return proc.text.trim() } catch (Exception e) { - return '' + System.err.println("WARNING: Failed to read gcloud project: ${e.message}") + return 'GCLOUD_CONFIG_ERROR' } }() } @@ -47,46 +49,43 @@ trace.sep = ',' trace.fields = 'name,native_id,status,submit,start,complete,duration,realtime,exit,%cpu,%mem,rss,peak_rss,error_action,attempt,cpu_model,workdir' trace.file = "trace--${params.experimentId}--" + new java.text.SimpleDateFormat("yyyy-MM-dd--HH-mm-ss").format(new Date()) + ".csv" +def scaledMemory = { task, baseMem -> + if ( task.exitStatus == 137 ) { + 1.GB * baseMem * task.attempt + } else { + // Do not scale further if mem was not issue last time + // Using task.attempts - 1 is inexact if a process experiences + // multiple failures. For example, a process can fail due to OOM + // (137), fail its first retry due to pre-emption (50001), then + // fail its second retry due to OOM (137). On its third attempt, it + // will get memory equivalent to if it had failed from OOM three + // times instead of twice. I feel it is better to overallocate than + // underallocate. Additionally, if users are seeing retries due to + // resource underallocation, they should just bump the base resource + // requests in their config files. + 1.GB * baseMem * Math.max(1, task.attempt - 1) + } +} + +def scaleTimeForRetry = { task, baseTime -> + if ( task.exitStatus == 140 ) { + 1.h * baseTime * task.attempt + } else { + // Do not scale further if time was not issue last time + 1.h * baseTime * Math.max(1, task.attempt - 1) + } +} + process { withLabel: parca { cpus = params.parca_cpus - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.parca_mem * task.attempt - } else { - // Do not scale further if mem was not issue last time - // Using task.attempts - 1 is inexact if a process experiences - // multiple failures. For example, a process can fail due to OOM - // (137), fail its first retry due to pre-emption (50001), then - // fail its second retry due to OOM (137). On its third attempt, it - // will get memory equivalent to if it had failed from OOM three - // times instead of twice. I feel it is better to overallocate than - // underallocate. Additionally, if users are seeing retries due to - // resource underallocation, they should just bump the base resource - // requests in their config files. - 1.GB * params.parca_mem * Math.max(1, task.attempt - 1) - } - } + memory = { scaledMemory(task, params.parca_mem) } } withLabel: analysis { cpus = params.analysis_cpus - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.analysis_mem * task.attempt - } else { - // Do not scale further if mem was not issue last time - 1.GB * params.analysis_mem * Math.max(1, task.attempt - 1) - } - } - } - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.sim_mem * task.attempt - } else { - // Do not scale further if mem was not issue last time - 1.GB * params.sim_mem * Math.max(1, task.attempt - 1) - } + memory = { scaledMemory(task, params.analysis_mem) } } + memory = { scaledMemory(task, params.sim_mem) } maxRetries = 3 cpus = params.sim_cpus } @@ -156,54 +155,20 @@ profiles { // required to make time directives only apply to SLURM profiles withLabel: parca { cpus = params.parca_cpus - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.parca_mem * task.attempt - } else { - 1.GB * params.parca_mem * Math.max(1, task.attempt - 1) - } - } - time = { - if ( task.exitStatus == 140 ) { - 1.h * params.parca_time * task.attempt - } else { - // Do not scale further if time was not issue last time - 1.h * params.parca_time * Math.max(1, task.attempt - 1) - } - } + memory = { scaledMemory(task, params.parca_mem) } + time = { scaleTimeForRetry(task, params.parca_time) } } withLabel: analysis { cpus = params.analysis_cpus - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.analysis_mem * task.attempt - } else { - // Do not scale further if mem was not issue last time - 1.GB * params.analysis_mem * Math.max(1, task.attempt - 1) - } - } - time = { - if ( task.exitStatus == 140 ) { - 1.h * params.analysis_time * task.attempt - } else { - // Do not scale further if time was not issue last time - 1.h * params.analysis_time * Math.max(1, task.attempt - 1) - } - } + memory = { scaledMemory(task, params.analysis_mem) } + time = { scaleTimeForRetry(task, params.analysis_time) } } container = params.container_image containerOptions = "-B ${params.publishDir}:${params.publishDir} -B ${launchDir}:${launchDir} ${params.slurm_env}" queue = params.queue clusterOptions = params.cluster_options executor = 'slurm' - time = { - if ( task.exitStatus == 140 ) { - 1.h * params.sim_time * task.attempt - } else { - // Do not scale further if time was not issue last time - 1.h * params.sim_time * Math.max(1, task.attempt - 1) - } - } + time = { scaleTimeForRetry(task, params.sim_time) } errorStrategy = { // Codes: 137 (OOM), 140 (SLURM job limits), 143 (SLURM preemption) // Default value for exitStatus is max integer value, this @@ -236,24 +201,11 @@ profiles { withLabel: parca { // Nextflow does not merge withLabel directives so must repeat cpus = params.parca_cpus - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.parca_mem * task.attempt - } else { - 1.GB * params.parca_mem * Math.max(1, task.attempt - 1) - } - } + memory = { scaledMemory(task, params.parca_mem) } executor = 'slurm' queue = params.queue clusterOptions = params.cluster_options - time = { - if ( task.exitStatus == 140 ) { - 1.h * params.parca_time * task.attempt - } else { - // Do not scale further if time was not issue last time - 1.h * params.parca_time * Math.max(1, task.attempt - 1) - } - } + time = { scaleTimeForRetry(task, params.parca_time) } } // Creating variants happens before the HyperQueue workers // are allocated and requires a separate SLURM job @@ -266,22 +218,8 @@ profiles { // stalling sims and allow custom resource requests withLabel: analysis { cpus = params.analysis_cpus - memory = { - if ( task.exitStatus == 137 ) { - 1.GB * params.analysis_mem * task.attempt - } else { - // Do not scale further if mem was not issue last time - 1.GB * params.analysis_mem * Math.max(1, task.attempt - 1) - } - } - time = { - if ( task.exitStatus == 140 ) { - 1.h * params.analysis_time * task.attempt - } else { - // Do not scale further if time was not issue last time - 1.h * params.analysis_time * Math.max(1, task.attempt - 1) - } - } + memory = { scaledMemory(task, params.analysis_mem) } + time = { scaleTimeForRetry(task, params.analysis_time) } executor = 'slurm' queue = params.queue clusterOptions = params.cluster_options @@ -291,14 +229,7 @@ profiles { containerOptions = "-B ${params.publishDir}:${params.publishDir} -B ${launchDir}:${launchDir} ${params.slurm_env}" // Use Nextflow's retry logic instead of HyperQueue's built-in logic clusterOptions = '--crash-limit=1' - time = { - if ( task.exitStatus == 140 ) { - 1.h * params.sim_time * task.attempt - } else { - // Do not scale further if time was not issue last time - 1.h * params.sim_time * Math.max(1, task.attempt - 1) - } - } + time = { scaleTimeForRetry(task, params.sim_time) } errorStrategy = { ((task.exitStatus in [137, 140, 143, Integer.MAX_VALUE]) && (task.attempt <= task.maxRetries)) ? 'retry' : 'ignore' From 41c71cb3cd39b22a2e5096301995678f30071b37 Mon Sep 17 00:00:00 2001 From: Sean Cheah Date: Tue, 19 May 2026 14:57:28 -0700 Subject: [PATCH 2/6] Document differences from Dockerfile --- runscripts/container/Singularity | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/runscripts/container/Singularity b/runscripts/container/Singularity index b552a1de8..1aebf4ace 100644 --- a/runscripts/container/Singularity +++ b/runscripts/container/Singularity @@ -32,7 +32,9 @@ From: ghcr.io/astral-sh/uv@sha256:2702577da32d9c3d55c8073e07d4476cd7402c085efbf7 tar -xf /repo.tar -C /vEcoli rm /repo.tar fi - apt-get update && apt-get install -y gcc procps nano curl g++ + # No need for ca-certificates and unzip since not installing AWS CLI + apt-get update && apt-get install -y gcc procps nano curl g++ \ + && apt-get clean && rm -rf /var/lib/apt/lists/* cd /vEcoli # Source shared uv environment variables . /vEcoli/singularity-env.sh From ab4bbd03f451474db879e4044cb0621488cab960 Mon Sep 17 00:00:00 2001 From: Sean Cheah Date: Tue, 19 May 2026 14:57:57 -0700 Subject: [PATCH 3/6] More robust hostname extraction --- runscripts/workflow.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/runscripts/workflow.py b/runscripts/workflow.py index d661ef1f8..754f40c03 100644 --- a/runscripts/workflow.py +++ b/runscripts/workflow.py @@ -1084,11 +1084,13 @@ def run_ecr_script(image: str, build: bool, region: str = "us-gov-west-1") -> st # or just : (script will create full URI) is_full_ecr_uri = False if "/" in image: - # Extract hostname from URI (part before first /) - hostname = image.split("/")[0] - # Verify hostname actually ends with .amazonaws.com to prevent - # bypass via URLs like evil.com/.amazonaws.com/path - is_full_ecr_uri = hostname.endswith(".amazonaws.com") + # Extract hostname from URI + parsed = parse.urlparse(image if "://" in image else f"//{image}") + hostname = (parsed.hostname or "").lower() + # Accept only amazonaws.com or its real subdomains + is_full_ecr_uri = hostname == "amazonaws.com" or hostname.endswith( + ".amazonaws.com" + ) if is_full_ecr_uri: # Full URI provided, extract repo:tag @@ -1319,8 +1321,6 @@ def stream_log( print(new_content, end="", flush=True) # Remember where we are now last_position = f.tell() - else: - break if stop_event is not None and stop_event.is_set(): break time.sleep(sleep_time) From a8869c0e76bcc8afdf7eff9492252012a0c73981 Mon Sep 17 00:00:00 2001 From: Sean Cheah Date: Tue, 19 May 2026 14:58:13 -0700 Subject: [PATCH 4/6] Clean up docstring --- ecoli/processes/transcript_elongation.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/ecoli/processes/transcript_elongation.py b/ecoli/processes/transcript_elongation.py index fa1ecf455..5d16d96a4 100644 --- a/ecoli/processes/transcript_elongation.py +++ b/ecoli/processes/transcript_elongation.py @@ -62,27 +62,28 @@ class TranscriptElongation(PartitionedProcess): defaults: - rnaPolymeraseElongationRateDict (dict): Array with elongation rate - set points for different media environments. + set points for different media environments. - rnaIds (array[str]) : array of names for each TU - rnaLengths (array[int]) : array of lengths for each TU - (in nucleotides?) + (in nucleotides?) - rnaSequences (2D array[int]) : Array with the nucleotide sequences - of each TU. This is in the form of a 2D array where each row is a - TU, and each column is a position in the TU's sequence. Nucleotides - are stored as an index {0, 1, 2, 3}, and the row is padded with - -1's on the right to indicate where the sequence ends. + of each TU. This is in the form of a 2D array where each row is a + TU, and each column is a position in the TU's sequence. Nucleotides + are stored as an index {0, 1, 2, 3}, and the row is padded with + -1's on the right to indicate where the sequence ends. - ntWeights (array[float]): Array of nucleotide weights - - endWeight (array[float]): ???, + - endWeight (array[float]): Additional mass added when an RNA + transcript is completed (termination/end-group mass adjustment), - replichore_lengths (array[int]): lengths of replichores - (in nucleotides?), + (in nucleotides?), - is_mRNA (array[bool]): Mask for mRNAs - ppi (str): ID of PPI - inactive_RNAP (str): ID of inactive RNAP - ntp_ids list[str]: IDs of ntp's (A, C, G, U) - variable_elongation (bool): Whether to use variable elongation. - False by default. + False by default. - make_elongation_rates: Function to make elongation rates, of the - form: lambda random, rates, timestep, variable: rates + form: lambda random, rates, timestep, variable: rates """ name = NAME @@ -151,7 +152,6 @@ def __init__(self, parameters=None): self.make_elongation_rates = self.parameters["make_elongation_rates"] self.polymerized_ntps = self.parameters["polymerized_ntps"] - self.charged_trna_names = self.parameters["charged_trnas"] # Attenuation self.trna_attenuation = self.parameters["trna_attenuation"] From 6ecfbab7e60acd8e964b9140682621d5088a0404 Mon Sep 17 00:00:00 2001 From: Sean Cheah Date: Tue, 19 May 2026 14:58:36 -0700 Subject: [PATCH 5/6] Do not silently drop lower-level analysis filters --- runscripts/analysis.py | 21 +++++- runscripts/test_analysis_comprehensive.py | 82 +++++++++++++++++++++++ 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/runscripts/analysis.py b/runscripts/analysis.py index d488ebf2d..4c8a2a0d4 100644 --- a/runscripts/analysis.py +++ b/runscripts/analysis.py @@ -339,6 +339,16 @@ def build_query_strings( var_id = row[var_id_idx] id_to_variants[data_id].add((exp_id, var_id)) + # Extract lower-level filter conditions (columns not in id_cols) so + # they are still applied in the per-subset SQL passed to analysis scripts. + lower_level_parts = [] + if duckdb_filter: + for condition in duckdb_filter.split(" AND "): + col_name = condition.strip().split(" ")[0] + if col_name not in id_cols: + lower_level_parts.append(condition.strip()) + lower_level_filter = " AND ".join(lower_level_parts) + for data_id, variant_set in id_to_variants.items(): data_filters = [] curr_outdir = os.path.abspath(outdir) @@ -350,10 +360,15 @@ def build_query_strings( data_filters.append(f"{col}={col_val}") os.makedirs(curr_outdir, exist_ok=True) data_filters = " AND ".join(data_filters) + full_filter = ( + f"{data_filters} AND {lower_level_filter}" + if lower_level_filter + else data_filters + ) query_strings[data_filters] = ( - f"SELECT * FROM ({history_sql}) WHERE {data_filters}", - f"SELECT * FROM ({config_sql}) WHERE {data_filters}", - f"SELECT * FROM ({success_sql}) WHERE {data_filters}", + f"SELECT * FROM ({history_sql}) WHERE {full_filter}", + f"SELECT * FROM ({config_sql}) WHERE {full_filter}", + f"SELECT * FROM ({success_sql}) WHERE {full_filter}", curr_outdir, variant_set, ) diff --git a/runscripts/test_analysis_comprehensive.py b/runscripts/test_analysis_comprehensive.py index a44dc4d73..32a36ad0a 100644 --- a/runscripts/test_analysis_comprehensive.py +++ b/runscripts/test_analysis_comprehensive.py @@ -617,6 +617,88 @@ def test_query_string_structure(self): assert isinstance(variant_set, set) assert ("exp1", 0) in variant_set + def test_lower_level_filter_included_in_sql(self, tmp_path): + """Lower-level filters (columns below id_cols) must appear in the SQL + strings passed to analysis scripts, not just in the discovery query.""" + # multigeneration id_cols = ["experiment_id", "variant", "lineage_seed"] + # generation is one level below and must survive into the per-subset SQL. + conn = MockConnection([("exp1", 0, 42)]) + analysis_type = "multigeneration" + duckdb_filter = "experiment_id = 'exp1' AND variant = 0 AND lineage_seed = 42 AND generation = 5" + + query_strings = build_query_strings( + analysis_type, + duckdb_filter, + "SELECT * FROM config", + "SELECT * FROM history", + "SELECT * FROM success", + str(tmp_path), + conn, + ) + + assert len(query_strings) == 1 + history_q, config_q, success_q, _, _ = next(iter(query_strings.values())) + assert "generation = 5" in history_q + assert "generation = 5" in config_q + assert "generation = 5" in success_q + + def test_no_lower_level_filters_leaves_sql_unchanged(self, tmp_path): + """When all duckdb_filter conditions are for id_cols, the per-subset SQL + must not gain any extra filter clauses.""" + conn = MockConnection([("exp1", 0, 42)]) + analysis_type = "multigeneration" + # All conditions are for id_cols — nothing lower-level. + duckdb_filter = "experiment_id = 'exp1' AND variant = 0 AND lineage_seed = 42" + + query_strings = build_query_strings( + analysis_type, + duckdb_filter, + "SELECT * FROM config", + "SELECT * FROM history", + "SELECT * FROM success", + str(tmp_path), + conn, + ) + + history_q, config_q, success_q, _, _ = next(iter(query_strings.values())) + # id_col specific values must be present + assert "experiment_id='exp1'" in history_q + assert "variant=0" in history_q + assert "lineage_seed=42" in history_q + # No unexpected lower-level columns + assert "generation" not in history_q + assert "agent_id" not in history_q + + def test_multiple_lower_level_filters_all_included(self, tmp_path): + """All lower-level filter conditions must appear in every SQL string.""" + # multiseed id_cols = ["experiment_id", "variant"] + # generation and agent_id are both lower-level for this analysis type. + conn = MockConnection([("exp1", 0)]) + analysis_type = "multiseed" + duckdb_filter = ( + "experiment_id = 'exp1' AND variant = 0" + " AND generation = 5 AND agent_id = 'agent_001'" + ) + + query_strings = build_query_strings( + analysis_type, + duckdb_filter, + "SELECT * FROM config", + "SELECT * FROM history", + "SELECT * FROM success", + str(tmp_path), + conn, + ) + + assert len(query_strings) == 1 + history_q, config_q, success_q, _, _ = next(iter(query_strings.values())) + for sql in (history_q, config_q, success_q): + assert "generation = 5" in sql + assert "agent_id = 'agent_001'" in sql + # id_col values must still be pinned + assert "experiment_id='exp1'" in sql + assert "variant=0" in sql + class TestIntegration: """Integration tests that test multiple functions together""" From 1c9254714936b839d3718d3c86924b9aecd4a776 Mon Sep 17 00:00:00 2001 From: Sean Cheah Date: Tue, 19 May 2026 15:01:02 -0700 Subject: [PATCH 6/6] Fix CVE-2025-68463 --- uv.lock | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/uv.lock b/uv.lock index bcd08e047..fcdf2e431 100644 --- a/uv.lock +++ b/uv.lock @@ -354,19 +354,18 @@ wheels = [ [[package]] name = "biopython" -version = "1.85" +version = "1.87" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "numpy" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/db/ca/1d5fab0fedaf5c2f376d9746d447cdce04241c433602c3861693361ce54c/biopython-1.85.tar.gz", hash = "sha256:5dafab74059de4e78f49f6b5684eddae6e7ce46f09cfa059c1d1339e8b1ea0a6", size = 19909902, upload-time = "2025-01-15T15:06:51.997Z" } +sdist = { url = "https://files.pythonhosted.org/packages/df/3e/3c6aa8b2a7e6b791a34407736db32f59657001f0446ada31db73a3e0b7d5/biopython-1.87.tar.gz", hash = "sha256:8456c803459b679a9712422e5a7fd9809f2f089bf69bb085f3b077946ac9bdbf", size = 19855264, upload-time = "2026-03-30T11:28:29.823Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/25/e46f05359df7f0049c3adc5eaeb9aee0f5fbde1d959d05c78eb1de8f4d12/biopython-1.85-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:cc5b981b9e3060db7c355b6145dfe3ce0b6572e1601b31211f6d742b10543874", size = 2789327, upload-time = "2025-01-15T15:13:17.086Z" }, - { url = "https://files.pythonhosted.org/packages/54/5b/8b3b029c94c63ab4c1781d141615b4a837e658422381d460c5573d5d8262/biopython-1.85-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6fe47d704c2d3afac99aeb461219ec5f00273120d2d99835dc0a9a617f520141", size = 2765805, upload-time = "2025-01-15T15:13:26.92Z" }, - { url = "https://files.pythonhosted.org/packages/69/0a/9a8a38eff03c4607b9cec8d0e08c76b346b1cee1f77bc6d00efebfc7ec83/biopython-1.85-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e54e495239e623660ad367498c2f7a1a294b1997ba603f2ceafb36fd18f0eba6", size = 3249276, upload-time = "2025-01-15T15:13:36.639Z" }, - { url = "https://files.pythonhosted.org/packages/b4/0d/b7a0f10f5100dcf51ae36ba31490169bfa45617323bd82af43e1fb0098fb/biopython-1.85-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d024ad48997ad53d53a77da24b072aaba8a550bd816af8f2e7e606a9918a3b43", size = 3268869, upload-time = "2025-01-15T15:13:44.009Z" }, - { url = "https://files.pythonhosted.org/packages/07/51/646a4b7bdb4c1153786a70d33588ed09178bfcdda0542dfdc976294f4312/biopython-1.85-cp312-cp312-win32.whl", hash = "sha256:6985e17a2911defcbd30275a12f5ed5de2765e4bc91a60439740d572fdbfdf43", size = 2787011, upload-time = "2025-01-15T15:13:48.958Z" }, - { url = "https://files.pythonhosted.org/packages/c1/84/c583fa2ac6e7d392d24ebdc5c99e95e517507de22cf143efb6cf1fc93ff5/biopython-1.85-cp312-cp312-win_amd64.whl", hash = "sha256:d6f8efb2db03f2ec115c5e8c764dbadf635e0c9ecd4c0e91fc8216c1b62f85f5", size = 2820972, upload-time = "2025-01-15T15:13:54.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/18f024658c364196b7f9519674edd3233136fa19874b7ffd9e55ea0fd8e6/biopython-1.87-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:77ccc634621904d4a8fa0a43b5e0f093fa9df8c9577ed3858af648bb3528f51e", size = 2680980, upload-time = "2026-03-30T11:37:58.626Z" }, + { url = "https://files.pythonhosted.org/packages/c1/40/37c45bb4b5e345664bd970c3294349d1e35d4ad5794f808324bbef6ff9e7/biopython-1.87-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3428155c3e0abbed7aad5ff08e034d435b84dfe560c8ec58e7d43abda4b6a43", size = 3220352, upload-time = "2026-03-30T11:38:03.619Z" }, + { url = "https://files.pythonhosted.org/packages/a9/28/7898c2061966d6d62f0bb2e53cd5e1b3bb3053d2bc431f299802faca23cc/biopython-1.87-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:65ba69ef0273e983a9036c2a228142bc34266179a5f03660fc281d332d718630", size = 3246483, upload-time = "2026-03-30T11:38:07.185Z" }, + { url = "https://files.pythonhosted.org/packages/54/d3/a594c8443dc8979b3c051aa266aa4af2d39762c4bb2c37fe6892a19a7713/biopython-1.87-cp312-cp312-win32.whl", hash = "sha256:b077777fd2c555434bdcee58743f6f860aa80e1e005d9671913aa73823c6a773", size = 2709063, upload-time = "2026-03-30T11:38:13.53Z" }, + { url = "https://files.pythonhosted.org/packages/96/1a/d5884c67b20d9ae2b8d93593c971363421fd04c3dc8f5c35530c18e1d6a7/biopython-1.87-cp312-cp312-win_amd64.whl", hash = "sha256:856e3d64f1f27db493474ff84916ed8572731a525e001c7d0d8f41a0fd187000", size = 2743967, upload-time = "2026-03-30T11:38:10.739Z" }, ] [[package]]