From aca62fc9f788b5c08e3a1288af116f0a78758888 Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Fri, 31 Jul 2026 10:57:21 -0400 Subject: [PATCH 01/10] Fixed bedclassifier bugs --- bedboss/bedclassifier/bedclassifier.py | 2 +- .../a0169b30e33ceaaf1614a83f56094727.bed.gz | Bin 0 -> 54271 bytes test/test_bedclassifier.py | 12 ++++++++++++ 3 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 scripts/bedclassifier_tuning/incorrect_file/a0169b30e33ceaaf1614a83f56094727.bed.gz diff --git a/bedboss/bedclassifier/bedclassifier.py b/bedboss/bedclassifier/bedclassifier.py index f2e93c2d..45aad686 100644 --- a/bedboss/bedclassifier/bedclassifier.py +++ b/bedboss/bedclassifier/bedclassifier.py @@ -108,7 +108,7 @@ def _read_bed_file(filepath: str, skiprows: int = 0) -> pd.DataFrame | None: else: raise BedTypeException(reason="Input is not a string or dataframe.") - df = df.dropna(axis=1) + df = df.dropna(axis=1, how="all") df.columns = range(len(df.columns)) num_cols = len(df.columns) compliant_columns = 0 diff --git a/scripts/bedclassifier_tuning/incorrect_file/a0169b30e33ceaaf1614a83f56094727.bed.gz b/scripts/bedclassifier_tuning/incorrect_file/a0169b30e33ceaaf1614a83f56094727.bed.gz new file mode 100644 index 0000000000000000000000000000000000000000..d6a9c67600d9901b6ca863e7c362cf955ef582f5 GIT binary patch literal 54271 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 IVNSII0EqmuNB{r; literal 0 HcmV?d00001 diff --git a/test/test_bedclassifier.py b/test/test_bedclassifier.py index 2982e85c..ab0a936e 100644 --- a/test/test_bedclassifier.py +++ b/test/test_bedclassifier.py @@ -32,6 +32,9 @@ BED_7_03 = f"{SIMPLE_EXAMPLES_DIR}/test_bed_7_03.bed" BED_GAPPED_PEAK = f"{SIMPLE_EXAMPLES_DIR}/example.gappedPeak.gz" BED_GAPPED_PEAK_RS = f"{SIMPLE_EXAMPLES_DIR}/example_2.gappedPeak.gz" +# 5-column bed whose 4th (float) column has a single NaN value; regression test +# for the sparse-NaN column drop that previously misclassified this as bed4+0. +BED_4_PLUS_1_SPARSE_NAN = f"{SIMPLE_EXAMPLES_DIR}/test_bed_4plus1_sparse_nan.bed.gz" class TestBedClassifier: @@ -225,6 +228,15 @@ def test_get_bed_classification( non_compliant_columns=3, ), ), + ( + BED_4_PLUS_1_SPARSE_NAN, + BedClassificationOutput( + bed_compliance="bed4+1", + data_format=DATA_FORMAT.BED_LIKE, + compliant_columns=4, + non_compliant_columns=1, + ), + ), ], ) def test_get_bed_classifications(self, values): From e7ea3c4b71c853074175b0cc01c5d856438388a1 Mon Sep 17 00:00:00 2001 From: nsheff Date: Mon, 3 Aug 2026 16:33:07 -0400 Subject: [PATCH 02/10] Remove duplicate sequence_fit < 0.60 check Refs #164 --- bedboss/refgenome_validator/main.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/bedboss/refgenome_validator/main.py b/bedboss/refgenome_validator/main.py index 8b82cfef..2678f961 100644 --- a/bedboss/refgenome_validator/main.py +++ b/bedboss/refgenome_validator/main.py @@ -369,8 +369,6 @@ def calculate_rating(self, compat_stats: CompatibilityStats) -> RatingModel: points_rating += 1 if sequence_fit < 0.60: points_rating += 1 - if sequence_fit < 0.60: - points_rating += 1 else: # if no chrom names were found during assessment From e50ea7d81558a3f67f77bb2790a5f622640d72d5 Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Mon, 3 Aug 2026 22:42:50 -0400 Subject: [PATCH 03/10] Adding snapshots functionality --- bedboss/cli.py | 2 + bedboss/scripts/snapshot.py | 515 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 1 + 3 files changed, 518 insertions(+) create mode 100644 bedboss/scripts/snapshot.py diff --git a/bedboss/cli.py b/bedboss/cli.py index f3a8c4b2..4d4408e3 100644 --- a/bedboss/cli.py +++ b/bedboss/cli.py @@ -8,6 +8,7 @@ from bedboss.bbuploader.cli import app_bbuploader from bedboss.qdrant_index.qdrant_cli import qdrant_app +from bedboss.scripts.snapshot import snapshot_app # commented and made new const here, because it speeds up help function, # from bbconf.const import DEFAULT_LICENSE @@ -906,3 +907,4 @@ def common( app.add_typer(app_bbuploader, name="geo") app.add_typer(qdrant_app, name="qdrant") +app.add_typer(snapshot_app, name="snapshot") diff --git a/bedboss/scripts/snapshot.py b/bedboss/scripts/snapshot.py new file mode 100644 index 00000000..b370e9fc --- /dev/null +++ b/bedboss/scripts/snapshot.py @@ -0,0 +1,515 @@ +""" +Bulk metadata snapshot of the bedbase database, plus its ``bedboss snapshot`` +CLI sub-app. + +Two-phase flow with a clean bedboss/bbconf boundary: + +- **Phase 1 (bedboss, always runs).** Connect to the database *through bbconf* + (``BedBaseAgent(...).config.db_engine.engine``) and do all the querying, + streaming, and Parquet/manifest processing here. Whole tables of interest are + selected and joined (``bed`` LEFT JOIN ``bed_metadata``, plus ``bedsets`` and + ``bedfile_bedset_relation``); the export column set and the Parquet schema are + derived from the bbconf ORM models' columns, so they cannot drift. Rows are + streamed to zstd Parquet through server-side cursors and never fully + materialized in memory. Each file is gated against a pre-scan ``count`` taken + in the same ``REPEATABLE READ`` snapshot. + +- **Phase 2 (bbconf, only with ``publish=True``).** After phase 1 succeeds, the + built artifacts are handed to ``agent.snapshot.add(...)`` as + ``BedSnapshotArtifact`` models — a single bbconf call that uploads every file + to S3 (under the fixed ``snapshot/`` prefix) *and* records it in the + ``bed_snapshots`` table. bedboss never touches boto3 or the ORM directly. + +Heavy imports (pyarrow, sqlalchemy, the bbconf agent) are deferred into the +functions that need them so importing this module for its CLI app stays cheap. +""" + +from __future__ import annotations + +import datetime +import hashlib +import json +import logging +from pathlib import Path + +import typer + +from bedboss.const import PKG_NAME + +_LOGGER = logging.getLogger(PKG_NAME) + +# Bump when the exported column set or types change in a breaking way. +SCHEMA_VERSION = 1 + +# Rows fetched per server-side cursor round trip / written per Parquet batch. +DEFAULT_BATCH_SIZE = 20_000 + + +# --------------------------------------------------------------------------- # +# CLI sub-app: ``bedboss snapshot ...`` (registered in bedboss/cli.py). +# --------------------------------------------------------------------------- # + +snapshot_app = typer.Typer( + pretty_exceptions_short=False, + pretty_exceptions_show_locals=False, + help="Create and manage bulk metadata snapshots of the bedbase database.", +) + + +@snapshot_app.command( + "new", + help="Build a new bulk metadata snapshot (Parquet exports + manifest), " + "optionally publishing it to S3 and the database.", +) +def snapshot_new( + bedbase_config: str = typer.Option( + ..., + help="Path to the bedbase config file", + exists=True, + file_okay=True, + readable=True, + ), + outfolder: str = typer.Option( + "exports", help="Local directory for the built artifacts" + ), + batch_size: int = typer.Option( + DEFAULT_BATCH_SIZE, help="Rows per server-side fetch / Parquet write batch" + ), + fail_threshold: float = typer.Option( + 0.01, help="Abort without publishing if this fraction of rows is missing" + ), + publish: bool = typer.Option( + False, help="Upload artifacts to S3 and record rows in bed_snapshots" + ), +): + run_snapshot( + bedbase_config=bedbase_config, + output_dir=outfolder, + batch_size=batch_size, + fail_threshold=fail_threshold, + publish=publish, + ) + + +@snapshot_app.command("list", help="List bulk metadata snapshots, newest first.") +def snapshot_list( + config: str = typer.Option(..., help="Path to the bedbase config file"), + file_type: str = typer.Option(None, help="Filter by file type"), + limit: int = typer.Option(100, help="Maximum number of rows to return"), + offset: int = typer.Option(0, help="Number of rows to skip"), +): + from bbconf import BedBaseAgent + + bbagent = BedBaseAgent(config, init_ml=False) + result = bbagent.snapshot.list(file_type=file_type, limit=limit, offset=offset) + print(f"Total snapshots: {result.count}") + for row in result.results: + print( + f"{row.creation_date} {row.file_type:18} " + f"rows={row.record_count} cksum={(row.checksum or '')[:12]} " + f"{row.file_path}" + ) + + +@snapshot_app.command("delete", help="Delete a snapshot index row by id.") +def snapshot_delete( + id: int = typer.Option(..., help="Snapshot row id"), + config: str = typer.Option(..., help="Path to the bedbase config file"), + remove_s3: bool = typer.Option( + True, help="Also delete the underlying S3 object" + ), +): + from bbconf import BedBaseAgent + + bbagent = BedBaseAgent(config, init_ml=False) + bbagent.snapshot.delete(id, remove_s3=remove_s3) + print(f"Snapshot {id} deleted from the bedbase database") + + +@snapshot_app.command( + "delete-by-checksum", help="Delete snapshot index row(s) by checksum." +) +def snapshot_delete_by_checksum( + checksum: str = typer.Option(..., help="SHA256 checksum of the snapshot file"), + config: str = typer.Option(..., help="Path to the bedbase config file"), + remove_s3: bool = typer.Option( + True, help="Also delete the underlying S3 object(s)" + ), +): + from bbconf import BedBaseAgent + + bbagent = BedBaseAgent(config, init_ml=False) + bbagent.snapshot.delete_by_checksum(checksum, remove_s3=remove_s3) + print(f"Snapshot(s) with checksum {checksum} deleted from the bedbase database") + + +# --------------------------------------------------------------------------- # +# Column plans derived from the bbconf ORM models (no hand-listed columns). +# --------------------------------------------------------------------------- # + + +class ColumnPlan: + """ + An ordered set of SQLAlchemy columns plus the matching Parquet schema. + + Built from whole ORM tables so the SELECT projection and the Parquet schema + are derived from one source and stay tied to the bbconf models. + """ + + def __init__(self, columns: list, from_clause=None): + import pyarrow as pa + + self.columns = columns + self.from_clause = from_clause + self.names = [c.name for c in columns] + self._pa_types = [_pa_type(c) for c in columns] + # Indices of JSON columns, whose values are serialized to a string. + self.json_idx = { + i for i, c in enumerate(columns) if _python_type(c) is dict + } + self.schema = pa.schema(list(zip(self.names, self._pa_types))) + + def select(self): + """A ``SELECT`` of exactly these columns (over the joined FROM, if any).""" + from sqlalchemy import select + + stmt = select(*self.columns) + if self.from_clause is not None: + stmt = stmt.select_from(self.from_clause) + return stmt + + def count_from(self): + """The table these columns' first entry belongs to, for ``count(*)``.""" + return self.columns[0].table + + +def _python_type(column): + try: + return column.type.python_type + except (NotImplementedError, AttributeError): + return str + + +def _pa_type(column): + """Map a SQLAlchemy column's Python type onto a pyarrow type.""" + import pyarrow as pa + + pytype = _python_type(column) + if pytype is bool: + return pa.bool_() + if pytype is int: + return pa.int64() + if pytype is float: + return pa.float64() + if pytype is datetime.datetime: + return pa.timestamp("us", tz="UTC") + if pytype is list: + return pa.list_(pa.string()) + if pytype is dict: + # JSON/JSONB -> serialized string. + return pa.string() + return pa.string() + + +def metadata_plan() -> ColumnPlan: + """``bed`` LEFT JOIN ``bed_metadata`` — all columns of both tables. + + The duplicate join key ``bed_metadata.id`` (equal to ``bed.id``) is dropped. + """ + from bbconf.db_utils import Bed, BedMetadata + + bed_cols = list(Bed.__table__.columns) + meta_cols = [c for c in BedMetadata.__table__.columns if c.name != "id"] + from_clause = Bed.__table__.outerjoin( + BedMetadata.__table__, Bed.__table__.c.id == BedMetadata.__table__.c.id + ) + return ColumnPlan(bed_cols + meta_cols, from_clause=from_clause) + + +def bedsets_plan() -> ColumnPlan: + from bbconf.db_utils import BedSets + + return ColumnPlan(list(BedSets.__table__.columns)) + + +def membership_plan() -> ColumnPlan: + from bbconf.db_utils import BedFileBedSetRelation + + return ColumnPlan(list(BedFileBedSetRelation.__table__.columns)) + + +# --------------------------------------------------------------------------- # +# Pure helpers (no DB, no network). +# --------------------------------------------------------------------------- # + + +def _coerce_row(row, json_idx: set) -> tuple: + """Turn a result row into a value tuple, serializing JSON columns.""" + values = list(row) + for i in json_idx: + if values[i] is not None: + values[i] = json.dumps(values[i]) + return tuple(values) + + +def rows_to_table(rows, schema): + """Convert a batch of row tuples (in schema column order) to a pyarrow Table.""" + import pyarrow as pa + + if rows: + columns = list(zip(*rows)) + else: + columns = [[] for _ in range(len(schema))] + arrays = [ + pa.array(list(col), type=schema.field(i).type) + for i, col in enumerate(columns) + ] + return pa.table(arrays, schema=schema) + + +def check_completeness(rows_written: int, expected_count: int, threshold: float): + """ + Refuse to publish a partial artifact. + + Raises if the fraction of rows missing versus the pre-scan ``count`` exceeds + ``threshold``. Extra rows (concurrent ingest) never trip it. + """ + if expected_count <= 0: + return + shortfall = (expected_count - rows_written) / expected_count + if shortfall > threshold: + raise RuntimeError( + f"Wrote {rows_written} rows but expected ~{expected_count} " + f"(short by {shortfall:.2%} > {threshold:.2%}). " + f"Refusing to publish a partial artifact." + ) + + +def sha256_file(path, chunk: int = 1 << 20) -> str: + """Streaming SHA256 of a file.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(chunk), b""): + h.update(block) + return h.hexdigest() + + +def file_entry(path) -> dict: + """Manifest entry describing a written file: name, bytes, sha256.""" + path = Path(path) + return { + "name": path.name, + "bytes": path.stat().st_size, + "sha256": sha256_file(path), + } + + +def write_parquet_batches(path, schema, batch_iter) -> int: + """ + Stream batches (lists of row tuples) into a zstd Parquet file. + + Returns the number of rows actually written (the value the manifest and the + integrity gate must use). + """ + import pyarrow.parquet as pq + + rows_written = 0 + writer = pq.ParquetWriter(str(path), schema, compression="zstd") + try: + for batch in batch_iter: + if not batch: + continue + writer.write_table(rows_to_table(batch, schema)) + rows_written += len(batch) + finally: + writer.close() + return rows_written + + +# --------------------------------------------------------------------------- # +# Database streaming (server-side cursors; sequential, unordered scans). +# --------------------------------------------------------------------------- # + + +def stream_plan(conn, plan: ColumnPlan, batch: int): + """Yield lists of coerced row tuples from a streamed, unordered scan.""" + result = conn.execute(plan.select().execution_options(yield_per=batch)) + for partition in result.partitions(): + yield [_coerce_row(row, plan.json_idx) for row in partition] + + +def count_rows(conn, plan: ColumnPlan) -> int: + from sqlalchemy import func, select + + return conn.execute( + select(func.count()).select_from(plan.count_from()) + ).scalar_one() + + +def build_exports( + conn, + out_dir: Path, + date_str: str, + batch_size: int, + fail_threshold: float, +) -> list[dict]: + """ + Write the three Parquet files, gating each against its pre-scan count. + + Returns a list of per-file records: path, name, file_type, record_count, + file_size, checksum. All reads happen in the caller's REPEATABLE READ + transaction. + """ + records = [] + + exports = [ + ("metadata", metadata_plan()), + ("bedsets", bedsets_plan()), + ("bedset_membership", membership_plan()), + ] + + for file_type, plan in exports: + expected = count_rows(conn, plan) + fname = f"bedbase_{file_type}_{date_str}.parquet" + path = out_dir / fname + _LOGGER.info(f"Exporting {file_type}: ~{expected} rows -> {fname}") + + rows_written = write_parquet_batches( + path, plan.schema, stream_plan(conn, plan, batch_size) + ) + check_completeness(rows_written, expected, fail_threshold) + + entry = file_entry(path) + record = { + "path": str(path), + "name": entry["name"], + "file_type": file_type, + "record_count": rows_written, + "file_size": entry["bytes"], + "checksum": entry["sha256"], + } + records.append(record) + _LOGGER.info( + f" wrote {rows_written} rows, {entry['bytes'] / 1e6:.1f} MB, " + f"sha256={entry['sha256'][:12]}…" + ) + + return records + + +def write_manifest( + out_dir: Path, + date_str: str, + records: list[dict], + started: str, + ended: str, + source_db: str, +) -> dict: + manifest = { + "schema_version": SCHEMA_VERSION, + "build_started": started, + "build_ended": ended, + "source_database": source_db, + "files": [ + { + "name": r["name"], + "file_type": r["file_type"], + "rows": r["record_count"], + "bytes": r["file_size"], + "sha256": r["checksum"], + } + for r in records + ], + } + path = out_dir / f"manifest_{date_str}.json" + path.write_text(json.dumps(manifest, indent=2)) + + entry = file_entry(path) + record = { + "path": str(path), + "name": entry["name"], + "file_type": "manifest", + "record_count": None, + "file_size": entry["bytes"], + "checksum": entry["sha256"], + } + return record + + +# --------------------------------------------------------------------------- # +# Orchestration. +# --------------------------------------------------------------------------- # + + +def run_snapshot( + bedbase_config: str, + output_dir: str | Path = "exports", + batch_size: int = DEFAULT_BATCH_SIZE, + fail_threshold: float = 0.01, + publish: bool = False, +) -> None: + """ + Build a bulk metadata snapshot, optionally publishing it. + + Args: + bedbase_config: Path to the bedbase config file. + output_dir: Local directory for the built artifacts. + batch_size: Rows per server-side fetch / Parquet write batch. + fail_threshold: Abort without publishing if this fraction of rows is + missing versus the pre-scan count. + publish: If True, upload the artifacts to S3 and record them in the + ``bed_snapshots`` table (both done inside bbconf). + """ + from bbconf.bbagent import BedBaseAgent + from bbconf.models.base_models import BedSnapshotArtifact + + out_dir = Path(output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + now = datetime.datetime.now(datetime.timezone.utc) + date_str = now.strftime("%Y_%m_%d") + started = now.isoformat() + + # Connect through bbconf; querying/processing stays here in bedboss. + agent = BedBaseAgent(bedbase_config, init_ml=False) + source_db = agent.config.config.database.database + + # One REPEATABLE READ transaction: counts and the full scans share a + # consistent snapshot. + with agent.config.db_engine.engine.connect().execution_options( + isolation_level="REPEATABLE READ" + ) as conn: + records = build_exports(conn, out_dir, date_str, batch_size, fail_threshold) + + ended = datetime.datetime.now(datetime.timezone.utc).isoformat() + manifest_record = write_manifest( + out_dir, date_str, records, started, ended, source_db + ) + _LOGGER.info(f"Manifest: {manifest_record['path']}") + + if not publish: + _LOGGER.info( + "publish=False; artifacts left on disk, nothing uploaded or recorded." + ) + return + + # Phase 2: hand off to bbconf, which does the S3 upload AND the SQL insert. + artifacts = [ + BedSnapshotArtifact( + path=r["path"], + file_type=r["file_type"], + record_count=r["record_count"], + file_size=r["file_size"], + checksum=r["checksum"], + schema_version=SCHEMA_VERSION, + ) + for r in records + [manifest_record] + ] + result = agent.snapshot.add(artifacts, creation_date=now) + + http_prefix = None + if agent.config.config.access_methods and agent.config.config.access_methods.http: + http_prefix = agent.config.config.access_methods.http.prefix + for row in result.results: + url = f"{http_prefix}{row.file_path}" if http_prefix else row.file_path + _LOGGER.info(f"Published {url}") diff --git a/pyproject.toml b/pyproject.toml index bdde93f1..bf715212 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,6 +35,7 @@ dependencies = [ "bbconf>=0.14.12", "refgenconf>=0.13.1", "pandas>=2.0.0", + "pyarrow>=15.0.0", "ubiquerg>=0.6.2", "bedms>=0.1.0", "pephubclient>=0.5.0", From 854b2188b03f7df1ff0601b594652647d3358ea3 Mon Sep 17 00:00:00 2001 From: Oleksandr <41573628+khoroshevskyi@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:28:18 -0400 Subject: [PATCH 04/10] Update bedboss/refgenome_validator/main.py --- bedboss/refgenome_validator/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bedboss/refgenome_validator/main.py b/bedboss/refgenome_validator/main.py index 2678f961..472a6ed0 100644 --- a/bedboss/refgenome_validator/main.py +++ b/bedboss/refgenome_validator/main.py @@ -368,7 +368,7 @@ def calculate_rating(self, compat_stats: CompatibilityStats) -> RatingModel: if sequence_fit < 0.90: points_rating += 1 if sequence_fit < 0.60: - points_rating += 1 + points_rating += 2 else: # if no chrom names were found during assessment From 713422d738b5b67aaa06e9c1c7b0c3ae1bebf11c Mon Sep 17 00:00:00 2001 From: nsheff Date: Fri, 7 Aug 2026 15:12:42 -0400 Subject: [PATCH 05/10] Move logging setup out of __init__.py --- bedboss/__init__.py | 51 ---------------------------------- bedboss/bbuploader/__init__.py | 12 -------- bedboss/cli.py | 17 +++++++++++- pyproject.toml | 2 +- 4 files changed, 17 insertions(+), 65 deletions(-) diff --git a/bedboss/__init__.py b/bedboss/__init__.py index 76f35b51..4c2de540 100644 --- a/bedboss/__init__.py +++ b/bedboss/__init__.py @@ -1,10 +1,7 @@ """Package-level data""" -import logging from importlib.metadata import version -import coloredlogs - __version__ = version(__package__) __package_name__ = "bedboss" @@ -23,51 +20,3 @@ "__package_name__", "__author__", ] - -_LOGGER = logging.getLogger("bedboss") -_LOGGER.propagate = False -coloredlogs.install( - logger=_LOGGER, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [BEDBOSS] %(message)s", -) - -_LOGGER_PIPESTAT = logging.getLogger("pipestat") -_LOGGER_PIPESTAT.propagate = False -coloredlogs.install( - logger=_LOGGER_PIPESTAT, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [PIPESTAT] %(message)s", -) - -_LOGGER_GENIML = logging.getLogger("geniml") -_LOGGER_GENIML.propagate = False -coloredlogs.install( - logger=_LOGGER_GENIML, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [GENIML] %(message)s", -) - -_LOGGER_BBCONF = logging.getLogger("bbconf") -_LOGGER_BBCONF.propagate = False -coloredlogs.install( - logger=_LOGGER_BBCONF, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [BBCONF] %(message)s", -) - -_LOGGER_PHC = logging.getLogger("pephubclient") -_LOGGER_PHC.propagate = False -coloredlogs.install( - logger=_LOGGER_PHC, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [PEPHUBCLIENT] %(message)s", -) - -_LOGGER_REF_CONF = logging.getLogger("refgenconf") -_LOGGER_REF_CONF.propagate = False -coloredlogs.install( - logger=_LOGGER_PHC, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [REFGENCONF] %(message)s", -) diff --git a/bedboss/bbuploader/__init__.py b/bedboss/bbuploader/__init__.py index 30f889ae..56009817 100644 --- a/bedboss/bbuploader/__init__.py +++ b/bedboss/bbuploader/__init__.py @@ -1,13 +1 @@ """Package-level data""" - -import coloredlogs -import logmuse - -from bedboss.bbuploader.constants import PKG_NAME - -_LOGGER = logmuse.init_logger(PKG_NAME) -coloredlogs.install( - logger=_LOGGER, - datefmt="%H:%M:%S", - fmt="[%(levelname)s] [%(asctime)s] [BBUPLOADER] %(message)s", -) diff --git a/bedboss/cli.py b/bedboss/cli.py index f3a8c4b2..9e057f9b 100644 --- a/bedboss/cli.py +++ b/bedboss/cli.py @@ -1,6 +1,7 @@ import os from importlib.metadata import version as _pkg_version +import logmuse import typer __version__ = _pkg_version("bedboss") @@ -900,8 +901,22 @@ def common( version: bool = typer.Option( None, "--version", "-v", callback=version_callback, help="App version" ), + verbosity: int = typer.Option( + None, "--verbosity", help="Set logging level: 1 (CRITICAL) to 5 (DEBUG)" + ), + logdev: bool = typer.Option(False, "--logdev", help="Use developer logging format"), + silent: bool = typer.Option(False, "--silent", help="Silence logging"), ): - pass + # This callback runs before any command, so it is where logging gets + # configured. Don't move this into `__init__.py`. + # + # Configure the root logger so that bedboss and its dependencies (pipestat, + # geniml, bbconf, pephubclient, refgenconf, bbuploader) all log through one + # handler. Each line is tagged with its logger name, so there is no need to + # attach a per-package handler to identify the source. + logmuse.init_logger( + "", make_root=True, verbosity=verbosity, devmode=logdev, silent=silent + ) app.add_typer(app_bbuploader, name="geo") diff --git a/pyproject.toml b/pyproject.toml index bdde93f1..fb650b61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Topic :: Scientific/Engineering :: Bio-Informatics", ] dependencies = [ - "logmuse>=0.2.7", + "logmuse>=0.3.1", "coloredlogs>=15.0.1", "eido>=0.2.5", "peppy>=0.40.7", From 3e1531608cd17b8ebf7149042d3a51888fc1a388 Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Sun, 16 Aug 2026 14:40:49 -0400 Subject: [PATCH 06/10] Removed pephub from bbconf --- bedboss/bbuploader/main.py | 2 -- bedboss/bedboss.py | 13 +------------ bedboss/bedboss_hpc.py | 2 -- bedboss/bedbuncher/bedbuncher.py | 8 -------- bedboss/cli.py | 8 -------- bedboss/utils.py | 1 - scripts/metadata_update/update_meta.py | 2 -- scripts/profiling/prof.py | 1 - 8 files changed, 1 insertion(+), 36 deletions(-) diff --git a/bedboss/bbuploader/main.py b/bedboss/bbuploader/main.py index ee626d0c..a8ee11c7 100644 --- a/bedboss/bbuploader/main.py +++ b/bedboss/bbuploader/main.py @@ -753,7 +753,6 @@ def _upload_gse( bedbase_config=bedbase_config, narrowpeak=required_metadata.narrowpeak, other_metadata=required_metadata.pep.model_dump(), - upload_pephub=True, upload_s3=True, upload_qdrant=True, force_overwrite=overwrite, @@ -831,7 +830,6 @@ def _upload_gse( name=gse, description=project.description, heavy=False, # TODO: set to False because can't handle bedset > 10 files - upload_pephub=True, upload_s3=True, no_fail=True, force_overwrite=overwrite_bedset, diff --git a/bedboss/bedboss.py b/bedboss/bedboss.py index 212abd33..0491b9d9 100644 --- a/bedboss/bedboss.py +++ b/bedboss/bedboss.py @@ -79,7 +79,6 @@ def run_all( update: bool = False, upload_qdrant: bool = False, upload_s3: bool = False, - upload_pephub: bool = False, lite: bool = False, # Universes universe: bool = False, @@ -90,7 +89,7 @@ def run_all( reference_genome_validator: ReferenceValidator = None, ) -> str: """ - Run bedboss: bedmaker -> bedqc -> bedclassifier -> bedstat -> upload to s3, qdrant, pephub, and bedbase. + Run bedboss: bedmaker -> bedqc -> bedclassifier -> bedstat -> upload to s3, qdrant, and bedbase. Args: input_file: Input file path. @@ -113,7 +112,6 @@ def run_all( update: Whether to update the record in the database. If True, overwrites 'force_overwrite'. Default: False. upload_qdrant: Whether to upload to qdrant. Default: False. upload_s3: Whether to upload to s3. - upload_pephub: Whether to push bedfiles and metadata to pephub. Default: False. lite: Whether to run lite version of the pipeline. Default: False. universe: Whether to add the sample as the universe. Default: False. universe_method: Method used to create the universe. @@ -280,7 +278,6 @@ def run_all( ref_validation=ref_valid_stats, license_id=license_id, upload_qdrant=upload_qdrant and not lite, - upload_pephub=upload_pephub, upload_s3=upload_s3, local_path=outfolder, overwrite=True, @@ -298,7 +295,6 @@ def run_all( ref_validation=ref_valid_stats, license_id=license_id, upload_qdrant=upload_qdrant and not lite, - upload_pephub=upload_pephub, upload_s3=upload_s3, local_path=outfolder, overwrite=force_overwrite, @@ -337,7 +333,6 @@ def insert_pep( force_overwrite: bool = False, update: bool = False, upload_s3: bool = False, - upload_pephub: bool = False, upload_qdrant: bool = False, no_fail: bool = False, standardize_pep: bool = False, @@ -366,7 +361,6 @@ def insert_pep( force_overwrite: Whether to overwrite the existing record. update: Whether to update the record in the database. Overwrites force_overwrite. Default: False. upload_s3: Whether to upload to s3. - upload_pephub: Whether to push bedfiles and metadata to pephub. Default: False. upload_qdrant: Whether to execute qdrant indexing. no_fail: Whether to raise an error if bedset was not added to the database. standardize_pep: Whether to standardize the pep file before processing by using bedms. Default: False. @@ -458,7 +452,6 @@ def insert_pep( update=update, upload_qdrant=upload_qdrant, upload_s3=upload_s3, - upload_pephub=upload_pephub, universe=pep_sample.get("universe"), universe_method=pep_sample.get("universe_method"), universe_bedset=pep_sample.get("universe_bedset"), @@ -486,7 +479,6 @@ def insert_pep( output_folder=output_folder, description=pep.description, heavy=bedset_heavy, - upload_pephub=upload_pephub, upload_s3=upload_s3, no_fail=no_fail, force_overwrite=force_overwrite, @@ -608,7 +600,6 @@ def reprocess_all( update=True, upload_qdrant=True, upload_s3=True, - upload_pephub=True, lite=False, universe=False, universe_method=None, @@ -714,7 +705,6 @@ def reprocess_one( update=True, upload_qdrant=True, upload_s3=True, - upload_pephub=True, lite=False, universe=False, universe_method=None, @@ -764,7 +754,6 @@ def reprocess_bedset( output_folder=output_folder, description=bedset_annot.description, heavy=heavy, - upload_pephub=False, upload_s3=heavy, no_fail=no_fail, force_overwrite=True, diff --git a/bedboss/bedboss_hpc.py b/bedboss/bedboss_hpc.py index b305b6fa..2e090b81 100644 --- a/bedboss/bedboss_hpc.py +++ b/bedboss/bedboss_hpc.py @@ -90,7 +90,6 @@ class BoolFlagSpec(BaseModel): "update": BoolFlagSpec(on="--update"), "upload_qdrant": BoolFlagSpec(on="--upload-qdrant", off="--no-upload-qdrant"), "upload_s3": BoolFlagSpec(on="--upload-s3", off="--no-upload-s3"), - "upload_pephub": BoolFlagSpec(on="--upload-pephub", off="--no-upload-pephub"), "no_fail": BoolFlagSpec(on="--no-fail"), "standardize_pep": BoolFlagSpec(on="--standardize-pep"), "lite": BoolFlagSpec(on="--lite"), @@ -160,7 +159,6 @@ class RunPepArgs(BaseModel): update: bool = False upload_qdrant: bool = True upload_s3: bool = True - upload_pephub: bool = True no_fail: bool = False license_id: Optional[str] = None standardize_pep: bool = False diff --git a/bedboss/bedbuncher/bedbuncher.py b/bedboss/bedbuncher/bedbuncher.py index ebcbc43a..8460b52a 100644 --- a/bedboss/bedbuncher/bedbuncher.py +++ b/bedboss/bedbuncher/bedbuncher.py @@ -99,7 +99,6 @@ def run_bedbuncher( description: str = None, annotation: dict = None, heavy: bool = False, - upload_pephub: bool = False, upload_s3: bool = False, no_fail: bool = False, force_overwrite: bool = False, @@ -118,7 +117,6 @@ def run_bedbuncher( annotation: Bedset annotation (author, source, summary, etc.). heavy: Whether to use heavy processing (add all columns to the database). If False, R-script won't be executed, only basic statistics will be calculated. - upload_pephub: Whether to create a view in pephub. upload_s3: Whether to upload files to s3. no_fail: Whether to raise an error if bedset was not added to the database. force_overwrite: Whether to overwrite the record in the database. @@ -158,7 +156,6 @@ def run_bedbuncher( bedid_list=bed_set, statistics=True, description=description, - upload_pephub=upload_pephub, upload_s3=upload_s3, plots=plots.model_dump(exclude_none=True, exclude_unset=True) if plots else {}, local_path=output_folder, @@ -175,7 +172,6 @@ def run_bedbuncher_form_pep( output_folder: str, bedset_name: str = None, heavy: bool = False, - upload_pephub: bool = False, upload_s3: bool = False, no_fail: bool = False, force_overwrite: bool = False, @@ -190,7 +186,6 @@ def run_bedbuncher_form_pep( bedset_name: Name of the bedset. heavy: Whether to use heavy processing (add all columns to the database). If False, R-script won't be executed, only basic statistics will be calculated. - upload_pephub: Whether to create a view in pephub. upload_s3: Whether to upload files to s3. no_fail: Whether to raise an error if bedset was not added to the database. force_overwrite: Whether to overwrite the record in the database. @@ -223,7 +218,6 @@ def run_bedbuncher_form_pep( name=bedset_name or pep_of_bed.name, description=pep_of_bed.description, heavy=heavy, - upload_pephub=upload_pephub, upload_s3=upload_s3, no_fail=no_fail, force_overwrite=force_overwrite, @@ -244,7 +238,6 @@ def run_bedbuncher_form_pep( # description="This is a description", # upload_s3=True, # no_fail=True, -# upload_pephub=True, # heavy=True, # ) @@ -252,5 +245,4 @@ def run_bedbuncher_form_pep( # "/home/bnt4me/virginia/repos/bbuploader/config_db_local.yaml", # bedset_name="pephub_test", # bedset_pep="khoroshevskyi/bedbunch:default", -# upload_pephub=True, # ) diff --git a/bedboss/cli.py b/bedboss/cli.py index 4d4408e3..a17f075d 100644 --- a/bedboss/cli.py +++ b/bedboss/cli.py @@ -101,7 +101,6 @@ def run_all( ), upload_qdrant: bool = typer.Option(False, help="Upload to Qdrant"), upload_s3: bool = typer.Option(False, help="Upload to S3"), - upload_pephub: bool = typer.Option(False, help="Upload to PEPHub"), # Universes universe: bool = typer.Option(False, help="Create a universe"), universe_method: str = typer.Option( @@ -145,7 +144,6 @@ def run_all( update=update, upload_qdrant=upload_qdrant, upload_s3=upload_s3, - upload_pephub=upload_pephub, universe=universe, universe_method=universe_method, universe_bedset=universe_bedset, @@ -182,7 +180,6 @@ def run_pep( ), upload_qdrant: bool = typer.Option(True, help="Upload to Qdrant"), upload_s3: bool = typer.Option(True, help="Upload to S3"), - upload_pephub: bool = typer.Option(True, help="Upload to PEPHub"), no_fail: bool = typer.Option(False, help="Do not fail on error"), license_id: str = typer.Option(DEFAULT_LICENSE, help="License ID"), standardize_pep: bool = typer.Option(False, help="Standardize the PEP using bedMS"), @@ -223,7 +220,6 @@ def run_pep( update=update, license_id=license_id, upload_s3=upload_s3, - upload_pephub=upload_pephub, upload_qdrant=upload_qdrant, no_fail=no_fail, standardize_pep=standardize_pep, @@ -268,7 +264,6 @@ def run_pep_hpc( update: bool = typer.Option(False, help="Update existing records"), upload_qdrant: bool = typer.Option(True, help="Upload to Qdrant"), upload_s3: bool = typer.Option(True, help="Upload to S3"), - upload_pephub: bool = typer.Option(True, help="Upload to PEPHub"), no_fail: bool = typer.Option(False, help="Do not fail on error"), license_id: str = typer.Option(DEFAULT_LICENSE, help="License ID"), standardize_pep: bool = typer.Option(False, help="Standardize the PEP using bedMS"), @@ -308,7 +303,6 @@ def run_pep_hpc( update=update, upload_qdrant=upload_qdrant, upload_s3=upload_s3, - upload_pephub=upload_pephub, no_fail=no_fail, license_id=license_id, standardize_pep=standardize_pep, @@ -585,7 +579,6 @@ def make_bedset( False, help="Force overwrite the output files" ), upload_s3: bool = typer.Option(False, help="Upload to S3"), - upload_pephub: bool = typer.Option(False, help="Upload to PEPHub"), no_fail: bool = typer.Option(False, help="Do not fail on error"), ): from bedboss.bedbuncher.bedbuncher import run_bedbuncher_form_pep @@ -596,7 +589,6 @@ def make_bedset( output_folder=outfolder, bedset_name=bedset_name, heavy=heavy, - upload_pephub=upload_pephub, upload_s3=upload_s3, no_fail=no_fail, force_overwrite=force_overwrite, diff --git a/bedboss/utils.py b/bedboss/utils.py index d1004654..48d79521 100644 --- a/bedboss/utils.py +++ b/bedboss/utils.py @@ -138,7 +138,6 @@ def example_bedbase_config(): "aws_secret_access_key": None, "bucket": "bedbase", }, - "phc": {"namespace": "bedbase", "name": "bedbase", "tag": "latest"}, } diff --git a/scripts/metadata_update/update_meta.py b/scripts/metadata_update/update_meta.py index 44b6e5e0..c249031a 100644 --- a/scripts/metadata_update/update_meta.py +++ b/scripts/metadata_update/update_meta.py @@ -17,7 +17,6 @@ def update_description(bed_id: str, new_description: str): bbconf.bed.update( identifier=bed_id, metadata={"description": new_description}, - upload_pephub=False, upload_s3=False, upload_qdrant=False, ) @@ -28,7 +27,6 @@ def update_assay(bed_id: str, new_assay: str): bbconf.bed.update( identifier=bed_id, metadata={"assay": new_assay}, - upload_pephub=False, upload_s3=False, upload_qdrant=False, ) diff --git a/scripts/profiling/prof.py b/scripts/profiling/prof.py index a756b5f6..833e851a 100644 --- a/scripts/profiling/prof.py +++ b/scripts/profiling/prof.py @@ -16,7 +16,6 @@ def runn(): input_file="/home/bnt4me/Downloads/test_chroms.bed.gz", input_type="bed", force_overwrite=True, - upload_pephub=True, upload_s3=True, upload_qdrant=True, name="test", From 52e468afdd70dd84305d6de0ff30eba101612318 Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Mon, 17 Aug 2026 15:51:53 -0400 Subject: [PATCH 07/10] Unified logger --- bedboss/__init__.py | 51 +++++++++++++++++++++++++++++++++++++++++++++ bedboss/cli.py | 2 +- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/bedboss/__init__.py b/bedboss/__init__.py index 4c2de540..76f35b51 100644 --- a/bedboss/__init__.py +++ b/bedboss/__init__.py @@ -1,7 +1,10 @@ """Package-level data""" +import logging from importlib.metadata import version +import coloredlogs + __version__ = version(__package__) __package_name__ = "bedboss" @@ -20,3 +23,51 @@ "__package_name__", "__author__", ] + +_LOGGER = logging.getLogger("bedboss") +_LOGGER.propagate = False +coloredlogs.install( + logger=_LOGGER, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] [BEDBOSS] %(message)s", +) + +_LOGGER_PIPESTAT = logging.getLogger("pipestat") +_LOGGER_PIPESTAT.propagate = False +coloredlogs.install( + logger=_LOGGER_PIPESTAT, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] [PIPESTAT] %(message)s", +) + +_LOGGER_GENIML = logging.getLogger("geniml") +_LOGGER_GENIML.propagate = False +coloredlogs.install( + logger=_LOGGER_GENIML, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] [GENIML] %(message)s", +) + +_LOGGER_BBCONF = logging.getLogger("bbconf") +_LOGGER_BBCONF.propagate = False +coloredlogs.install( + logger=_LOGGER_BBCONF, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] [BBCONF] %(message)s", +) + +_LOGGER_PHC = logging.getLogger("pephubclient") +_LOGGER_PHC.propagate = False +coloredlogs.install( + logger=_LOGGER_PHC, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] [PEPHUBCLIENT] %(message)s", +) + +_LOGGER_REF_CONF = logging.getLogger("refgenconf") +_LOGGER_REF_CONF.propagate = False +coloredlogs.install( + logger=_LOGGER_PHC, + datefmt="%H:%M:%S", + fmt="[%(levelname)s] [%(asctime)s] [REFGENCONF] %(message)s", +) diff --git a/bedboss/cli.py b/bedboss/cli.py index 3471bf93..507df1c8 100644 --- a/bedboss/cli.py +++ b/bedboss/cli.py @@ -417,7 +417,7 @@ def reprocess_bedset( ) -@app.command(help=f"Create a bed files form a [{', '.join(options_list)}] file") +@app.command(help=f"Create a bed files from a [{', '.join(options_list)}] file") def make_bed( input_file: str = typer.Option( ..., From 4da8238f733e62850d2f3f981531433f81c7eadf Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Tue, 18 Aug 2026 14:53:45 -0400 Subject: [PATCH 08/10] Added analysis file upload functionality --- bedboss/cli.py | 2 + bedboss/scripts/analysis_files.py | 147 ++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 bedboss/scripts/analysis_files.py diff --git a/bedboss/cli.py b/bedboss/cli.py index 507df1c8..ef4507ce 100644 --- a/bedboss/cli.py +++ b/bedboss/cli.py @@ -9,6 +9,7 @@ from bedboss.bbuploader.cli import app_bbuploader from bedboss.qdrant_index.qdrant_cli import qdrant_app +from bedboss.scripts.analysis_files import files_app from bedboss.scripts.snapshot import snapshot_app # commented and made new const here, because it speeds up help function, @@ -915,3 +916,4 @@ def common( app.add_typer(app_bbuploader, name="geo") app.add_typer(qdrant_app, name="qdrant") app.add_typer(snapshot_app, name="snapshot") +app.add_typer(files_app, name="files") diff --git a/bedboss/scripts/analysis_files.py b/bedboss/scripts/analysis_files.py new file mode 100644 index 00000000..7f34893c --- /dev/null +++ b/bedboss/scripts/analysis_files.py @@ -0,0 +1,147 @@ +""" +Upload and manage standalone analysis files (openSignalMatrix, models, other +analysis inputs), plus the ``bedboss files`` CLI sub-app. + +Clean bedboss/bbconf boundary, same as ``bedboss snapshot``: + +- bedboss computes the local file's ``sha256`` and size, then hands an + ``AnalysisFileArtifact`` to ``agent.analysis_files.add(...)`` — a single + bbconf call that uploads the file to S3 (under the fixed ``analysis_files/`` + prefix) *and* records it in the ``analysis_files`` table. bedboss never + touches boto3 or the ORM directly. + +Heavy imports (the bbconf agent) are deferred into the functions that need them +so importing this module for its CLI app stays cheap. +""" + +from __future__ import annotations + +import hashlib +import logging +import os + +import typer + +from bedboss.const import PKG_NAME + +_LOGGER = logging.getLogger(PKG_NAME) + + +files_app = typer.Typer( + pretty_exceptions_short=False, + pretty_exceptions_show_locals=False, + help="Upload and manage standalone analysis files " + "(openSignalMatrix, models, other analysis inputs).", +) + + +def sha256_file(path: str, chunk: int = 1 << 20) -> str: + """Streaming SHA256 of a file.""" + h = hashlib.sha256() + with open(path, "rb") as f: + for block in iter(lambda: f.read(chunk), b""): + h.update(block) + return h.hexdigest() + + +@files_app.command( + "upload", + help="Upload an analysis file to S3 and record it in the analysis_files table.", +) +def files_upload( + path: str = typer.Option( + ..., + help="Local path to the file to upload", + exists=True, + file_okay=True, + readable=True, + ), + name: str = typer.Option(..., help="Logical name/key, e.g. openSignalMatrix"), + config: str = typer.Option(..., help="Path to the bedbase config file"), + file_type: str = typer.Option( + None, help="Category, e.g. openSignalMatrix | reference | model" + ), + genome: str = typer.Option(None, help="Genome/assembly, e.g. hg38 (optional)"), + description: str = typer.Option(None, help="Free-text description"), + tag: list[str] = typer.Option( + None, "--tag", help="Tag to attach; repeat for multiple tags" + ), +): + from bbconf import BedBaseAgent + from bbconf.models.base_models import AnalysisFileArtifact + + checksum = sha256_file(path) + file_size = os.path.getsize(path) + + artifact = AnalysisFileArtifact( + path=path, + name=name, + file_type=file_type, + genome=genome, + description=description, + tags=list(tag) if tag else None, + file_size=file_size, + checksum=checksum, + ) + + bbagent = BedBaseAgent(config, init_ml=False) + result = bbagent.analysis_files.add(artifact) + row = result.results[0] + print( + f"Uploaded analysis file '{row.name}' (id={row.id}) -> {row.file_path} " + f"[{file_size} bytes, sha256={checksum[:12]}…]" + ) + + +@files_app.command("list", help="List analysis files, newest first.") +def files_list( + config: str = typer.Option(..., help="Path to the bedbase config file"), + file_type: str = typer.Option(None, help="Filter by file type"), + genome: str = typer.Option(None, help="Filter by genome/assembly"), + tag: str = typer.Option(None, help="Filter by a single tag"), + limit: int = typer.Option(100, help="Maximum number of rows to return"), + offset: int = typer.Option(0, help="Number of rows to skip"), +): + from bbconf import BedBaseAgent + + bbagent = BedBaseAgent(config, init_ml=False) + result = bbagent.analysis_files.list( + file_type=file_type, genome=genome, tag=tag, limit=limit, offset=offset + ) + print(f"Total analysis files: {result.count}") + for row in result.results: + print( + f"id={row.id} {row.creation_date} {(row.name or ''):20} " + f"type={row.file_type} genome={row.genome} " + f"cksum={(row.checksum or '')[:12]} {row.file_path}" + ) + + +@files_app.command("delete", help="Delete an analysis file by id.") +def files_delete( + id: int = typer.Option(..., help="Analysis file row id"), + config: str = typer.Option(..., help="Path to the bedbase config file"), + remove_s3: bool = typer.Option(True, help="Also delete the underlying S3 object"), +): + from bbconf import BedBaseAgent + + bbagent = BedBaseAgent(config, init_ml=False) + bbagent.analysis_files.delete(id, remove_s3=remove_s3) + print(f"Analysis file {id} deleted from the bedbase database") + + +@files_app.command("delete-by-checksum", help="Delete analysis file(s) by checksum.") +def files_delete_by_checksum( + checksum: str = typer.Option(..., help="SHA256 checksum of the analysis file"), + config: str = typer.Option(..., help="Path to the bedbase config file"), + remove_s3: bool = typer.Option( + True, help="Also delete the underlying S3 object(s)" + ), +): + from bbconf import BedBaseAgent + + bbagent = BedBaseAgent(config, init_ml=False) + bbagent.analysis_files.delete_by_checksum(checksum, remove_s3=remove_s3) + print( + f"Analysis file(s) with checksum {checksum} deleted from the bedbase database" + ) From 1cec6ec28228744628b5130f1da7c687219b6f51 Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Tue, 18 Aug 2026 15:04:14 -0400 Subject: [PATCH 09/10] Updated changelog and version --- docs/changelog.md | 8 ++++++++ pyproject.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/docs/changelog.md b/docs/changelog.md index 64e1afe4..2376ab52 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,14 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html) and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. +# [0.12.0] - 2026-08-18 +## Changes: +- All references to `upload_pephub` have been removed from function signatures, docstrings, and internal logic across the codebase, including in `bedboss.py`, `bedbuncher.py`, `bbuploader/main.py`, `bedboss_hpc.py`, and the CLI (`cli.py`). This means users can no longer upload to PEPHub using any part of the pipeline. +- Removed unused logging setup from `bedboss/bbuploader/__init__.py` and added `logmuse` import to `cli.py` for consistent logging across the CLI. +- Fixed a bug in `_read_bed_file` in `bedclassifier.py` to only drop columns that are entirely NA, instead of any column with any NA. +- Added import for `snapshot_app` in `cli.py` from `bedboss.scripts.snapshot`. +- Added Analysis files functionality. + # [0.11.1] - 2026-04-22 ## Changed: - Migrated to new yacman version 1.0.0 diff --git a/pyproject.toml b/pyproject.toml index fa362579..fcfe6846 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bedboss" -version = "0.11.1" +version = "0.12.0" description = "Pipelines for genomic region file to produce bed files, and its statistics" readme = "README.md" license = "BSD-2-Clause" From 6a0c8f638302dd2f41d4c9fac915f833b1fa88e9 Mon Sep 17 00:00:00 2001 From: khoroshevskyi Date: Tue, 18 Aug 2026 15:22:25 -0400 Subject: [PATCH 10/10] Updated bbconf version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fcfe6846..1f1810a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,7 +32,7 @@ dependencies = [ "yacman>=1.0.0", "requests>=2.28.2", "piper>=0.14.3", - "bbconf>=0.14.12", + "bbconf>=0.15.0", "refgenconf>=0.13.1", "pandas>=2.0.0", "pyarrow>=15.0.0",