diff --git a/README.md b/README.md index c9dc286c..3193c9e4 100755 --- a/README.md +++ b/README.md @@ -81,26 +81,24 @@ $ git clone git@github.com:ncsa/NEAT.git $ cd NEAT ``` -A quick form of installation uses `bioconda`. Once `conda` is installed, the following command can be run for easy setup. -In the NEAT repo, at the base level is the environment.yml file you will need. Change directories into the neat repository -and run: +A quick form of installation uses `bioconda`. You must run these commands inside the NEAT project directory. ```bash -(base) $ conda env create -f environment.yml +(base) $ conda create -n neat -c conda-forge -c bioconda neat (base) $ conda activate neat -(neat) $ poetry install (neat) $ neat --help # tests that NEAT has installed correctly ``` Alternatively, instead of the `bioconda` method, you can use the `poetry` module in build a wheel file, which can then be `pip` installed. -You will need to run these commands from within the NEAT directory: +Once `conda` is installed, the following command can be run for easy setup. +In the NEAT repository, at the base level is the `environment.yml` file you will need. Change directories into the NEAT repository +and run: ```bash (base) $ conda env create -f environment.yml (base) $ conda activate neat -(neat) $ poetry build -(neat) $ pip install dist/neat*whl +(neat) $ poetry install (neat) $ neat --help # tests that NEAT has installed correctly ``` diff --git a/config_template/simple_template.yml b/config_template/simple_template.yml index 21769af9..2520a09f 100644 --- a/config_template/simple_template.yml +++ b/config_template/simple_template.yml @@ -26,8 +26,8 @@ rng_seed: . min_mutations: . overwrite_output: . -mode: . -size: . +parallel_mode: . +parallel_block_size: . threads: . cleanup_splits: . reuse_splits: . diff --git a/config_template/template_neat_config.yml b/config_template/template_neat_config.yml index 84d2c3af..2471c627 100644 --- a/config_template/template_neat_config.yml +++ b/config_template/template_neat_config.yml @@ -1,25 +1,27 @@ -## Template for gen_reads parallel +## Template for NEAT's read-simulator (as of version 4.3.5, parallelization-friendly) ## Any parameter that is not required but has a default value will use the ## default value even if the variable is not included in the config. For -## required items, they must be included in the config and the must be given a value. +## required items, they must be included in the config and they must be given a value. ## All other items can be present or not. If present and the value is set to a single ## period, the variable will be treated as though it had been omitted. Please do -## not modify this template, but instead make a copy in your working directory. Done this -## way, you can run without even needing to declare -c. +## not modify this template, but instead make a copy in your working directory. +## Run with: neat read-simulator -c -o [-p ] -# Absolute path to input reference fasta file +# Absolute path to input reference FASTA file # type = string | required: yes reference: REQUIRED -# Read length of the reads in the fastq output. Only required if @produce_fastq is set to true -# type = int | required: no | default = 101 +# Read length of the reads in the FASTQ output. Only used if produce_fastq = true +# type = int | required: no | default = 151 read_len: . -# Average Coverage for the entire genome. -# type = float | required: no | default = 10.0 +# Average coverage for the entire genome +# type = int | required: no | default = 10 coverage: . -# Absolute path to file with sequencing error model +# Absolute path to file with sequencing error model or quality-score model +# Error models are typically produced by neat model-seq-err (from FASTQ/BAM-like inputs) +# Quality-score models can be produced by neat model-qual-score (optionally fit with --markov) # type = string | required: no | default: /neat/models/defaults/default_error_model.pickle.gz error_model: . @@ -27,13 +29,11 @@ error_model: . # type = float | required = no | must be between 0.0 and 0.3 avg_seq_error: . -# This scales the quality scores to match the desired average sequencing error rate -# specified by avg_seq_error. +# Scale quality scores to match avg_seq_error # type: boolean | required = no | default = false rescale_qualities: . -# This is the factor to add to the quality scores to get the ascii text version of the -# score. The default follows the sanger quality offset +# PHRED quality offset (e.g., 33 for Sanger) # type: int | required = no | default = 33 quality_offset: . @@ -41,126 +41,99 @@ quality_offset: . # type = int | required = no | default = 2 ploidy: . -# Absolute path to vcf file containing variants that will always be included, regardless -# of genotype and filter. You can pre-filter your vcf for these fields before inputting it -# if this is not the desired behavior. +# Absolute path to VCF file containing variants that will always be included # type: string | required = no include_vcf: . -# Absolute path to bed file containing reference regions that the simulation -# should target. +# Absolute path to BED file containing reference regions that the simulation should target # type = string | required = no target_bed: . -# Scalar value for coverage in regions outside the targeted bed. Example 0.5 -# would get you roughly half the coverage as the on target areas. Default is -# 0 coverage in off-target regions. Number should be a float in decimal. -# type: float | required = no | default = 0.00 -off_target_scalar: . - -# Absolute path to bed file containing reference regions that the simulation -# should discard. +# Absolute path to BED file containing reference regions that the simulation should discard # type = string | required = no discard_bed: . -# Absolute path to the mutation model pickle file. Omitting this value will cause -# NEAT to use the default model, with some standard parameters, and generally uniform biases. +# Absolute path to the mutation model pickle file +# Typically produced by neat gen-mut-model using a reference FASTA and variants VCF +# Uses trinucleotide context and a trinucleotide transition-matrix to select sites/alleles # type: string | required = no mutation_model: . -# Average mutation rate per base pair. Overall average is 0.001, or model default -# Use either this value to override the mutation rate for the default or input model. +# Average mutation rate per base pair (overrides model mutation rate, if set) # type: float | required = no | must be between 0.0 and 0.3 mutation_rate: . -# Absolute path to a bed file with mutation rates by region. -# Rates must be in the fourth column and be of the form "mut_rate=x.xx" -# Rates must be between 0.00 and 0.03 +# Absolute path to a BED file with mutation rates by region +# Rates must be in the third column and be of the form "mut_rate=x.xx" +# Rates must be between 0.00 and 0.30 # type: string | required = no mutation_bed: . -# Whether the output should be paired ended. For certain conditions (i.e., vcf only or -# fasta only), this will be ignored. If this is true, then there must be an included fragment -# length model output from runner.py or a mean and standard deviation -# by declaring values for @fragment_mean and @fragment_std_dev. +# Paired-end output mode +# If true, you must provide either fragment_model OR (fragment_mean + fragment_st_dev) # type: boolean | required = no | default = false paired_ended: . -# Absolute path to a pickle file containing the fragment length model output -# from runner.py. +# Absolute path to a pickle file containing the fragment length model +# Typically produced by neat model-fraglen (learned from BAM alignments) # type: string | required = no | default: /neat/models/defaults/default_fraglen_model.pickle.gz fragment_model: . -# Mean for the paired end fragment length. This only applies if paired-ended is set to true. -# This number will form the mean for the sample distribution of the fragment lengths in the simulation -# Note: This number is REQUIRED if paired_ended is set to true, unless a fragment length model is used. -# type: float | required: no (unless paired-ended) +# Mean paired-end fragment length (used only if paired_ended = true and fragment_model is not set) +# type: float | required: no (unless paired_ended and no fragment_model) fragment_mean: . -# Standard deviation for the paired end fragment length. This only applies if paired-ended is set to true. -# This number will form the standard deviation about the mean specified above for the sample distribution -# of the fragment lengths in the simulation. -# Note: This number is REQUIRED if paired_ended is set to true, unless a fragment length model is used. -# type: float | required: no (unless paired-ended) +# Standard deviation of paired-end fragment length distribution +# type: float | required: no (unless paired_ended and no fragment_model) fragment_st_dev: . -# Whether to produce the golden bam file. This file will contain the reads -# aligned with the exact region of the genome +# Produce golden BAM file (aligned reads) # type: boolean | required = no | default = false produce_bam: . -# Whether to produce a vcf file containing all the mutation errors added -# by NEAT. +# Produce golden VCF file (all NEAT-added variants) # type: boolean | required = no | default = false produce_vcf: . -# Whether to output the fastq(s) of the reads. This is the default output. NEAT -# will produce 1 fastq for single ended reads or 2 fastqs for paired ended. +# Produce FASTQ output (default output) # type: boolean | required = no | default = true produce_fastq: . -# If set to true, this will ignore statistical models and force coverage to be -# constant across the genome. This is considered a debugging feature. +# Ignore coverage bias models and force constant coverage (debugging) # type: boolean | required = no | default = false no_coverage_bias: . -# Set an RNG seed value. Runs using identical RNG values should produce identical results -# so things like read locations, variant positions, error positions, etc. should be the same. -# Useful for debugging. +# RNG seed for reproducibility # type: int | required = no rng_seed: . -# Set an absolute minimum number of mutations. The program always adds at least 1 mutation. -# Useful for very small datasets. -# type: int | required = no +# Absolute minimum number of mutations (NEAT always adds at least 1) +# type: int | required = no | default = 0 min_mutations: . -# Overwrite the output files, if they are named the same as the current run. -# Default is to quit if files already exist to avoid data destruction +# Overwrite output files if they already exist # type: bool | required = no | default = false overwrite_output: . # How to split the input reference for parallelization -# Note if threads == 1, this option has no effect. +# Note: if threads == 1, this option has no effect (treated as contig) # type = string | required: no | default = contig | values: contig, size parallel_mode: . -# Target block size if by = size (overlap = read_len * 2). -# Default is 500000 when by = size. Not used for by = contig. -# type = int | required: no | default = 500000 (when by=size) +# Target block size if parallel_mode = size (overlap = read_len * 2) +# type = int | required: no | default = 500000 (when parallel_mode=size) parallel_block_size: . -# Maximum number of concurrent NEAT jobs (threads or hyperthreads) to run. -# type = int | required: no | default = all available. +# Number of worker processes/threads to run +# type = int | required: no | default = 1 threads: . -# Delete the 'splits' directory after stitching completes -# Note if threads == 1, this option has no effect. +# If true, delete splits after stitching completes +# Set false to preserve splits in /splits for reuse # type = bool | required: no | default = true cleanup_splits: . -# Reuse existing files in '/splits' and skip the split step. -# The directory must contain neat-generated files and must be in the output dir within "splits" -# Note if threads == 1, this option has no effect. -# type = bool | required: no | default = False -reuse_splits: . \ No newline at end of file +# Reuse existing files in '/splits' and skip the split step +# Directory must exist and contain NEAT-generated split files +# type = bool | required: no | default = false +reuse_splits: . diff --git a/neat/cli/cli.py b/neat/cli/cli.py index d7abd353..f97c2599 100644 --- a/neat/cli/cli.py +++ b/neat/cli/cli.py @@ -1,6 +1,9 @@ """Implements command line interface used by the package.""" __all__ = ['Cli', 'main', 'run'] +__version__ = "4.3.6" +__author__ = "Joshua Allen" +__email__ = "jallen17@illinois.edu" import argparse import importlib @@ -41,6 +44,11 @@ def __init__(self): self.parser = argparse.ArgumentParser( prog="neat", description="Run NEAT components" ) + self.parser.add_argument( + "-v", "--version", + action="version", + version="%(prog)s {version}".format(version=__version__), + ) self.parser.add_argument( "--no-log", default=False, diff --git a/neat/cli/commands/options.py b/neat/cli/commands/options.py index 274a973a..765fb215 100644 --- a/neat/cli/commands/options.py +++ b/neat/cli/commands/options.py @@ -14,6 +14,7 @@ "--output_dir", dest="output_dir", type=str, + required=True, help="Path to the output directory. Will create if not present.", default=os.getcwd() ) diff --git a/neat/cli/commands/read_simulator.py b/neat/cli/commands/read_simulator.py index 6438e34b..e1c6e217 100644 --- a/neat/cli/commands/read_simulator.py +++ b/neat/cli/commands/read_simulator.py @@ -33,7 +33,7 @@ def add_arguments(self, parser: argparse.ArgumentParser): "-c", "--config", metavar="config", type=str, - required=False, + required=True, help="Path (including filename) to the configuration file for this run." ) diff --git a/neat/read_simulator/runner.py b/neat/read_simulator/runner.py index a756c7bb..424b6ed2 100644 --- a/neat/read_simulator/runner.py +++ b/neat/read_simulator/runner.py @@ -2,7 +2,7 @@ Runner for generate_reads task """ import logging -import os +import shutil import subprocess import time import multiprocessing as mp @@ -239,7 +239,7 @@ def read_simulator_runner(config: str, output_dir: str, file_prefix: str): temp_file = str(options.temp_dir_path / "temp.sorted.vcf.gz") subprocess.run(["bcftools", "sort", "-o", temp_file, "-Ob9", str(file)]) Path(temp_file).is_file() - os.rename(temp_file, str(file)) + shutil.move(temp_file, str(file)) _LOG.info("Indexing vcf") pysam.tabix_index(str(file), preset="vcf", force=force) diff --git a/neat/read_simulator/utils/options.py b/neat/read_simulator/utils/options.py index 16290908..cf897761 100644 --- a/neat/read_simulator/utils/options.py +++ b/neat/read_simulator/utils/options.py @@ -155,7 +155,7 @@ def __init__(self, self.discard_bed: Path | None = discard_bed self.mutation_model: Path | None = mutation_model self.mutation_rate: float | None = mutation_rate - self.mutation_bed: str | None = mutation_bed + self.mutation_bed: Path | None = mutation_bed self.quality_offset: int = quality_offset self.paired_ended: bool = paired_ended @@ -237,8 +237,8 @@ def from_cli(output_dir: Path, 'rng_seed': (int, None, None, None), 'min_mutations': (int, 0, None, None), 'overwrite_output': (bool, False, None, None), - 'mode': (str, 'size', 'choice', ['size', 'contig']), - 'size': (int, 500000, None, None), + 'parallel_mode': (str, 'size', 'choice', ['size', 'contig']), + 'parallel_block_size': (int, 500000, None, None), 'threads': (int, 1, 1, 1000), 'cleanup_splits': (bool, True, None, None), 'reuse_splits': (bool, False, None, None) @@ -266,7 +266,7 @@ def from_cli(output_dir: Path, # Update items to config or default values base_options.__dict__.update(final_args) - base_options.set_random_seed() + base_options.rng = base_options.set_random_seed() # Some options checking to clean up the args dict base_options.check_options() @@ -288,7 +288,7 @@ def check_and_log_error(keyname, value_to_check, crit1, crit2): if value_to_check not in crit2: _LOG.error(f"Must choose one of {crit2}") sys.exit(1) - elif isinstance(crit1, int) and isinstance(crit2, int): + elif isinstance(crit1, (int, float)) and isinstance(crit2, (int, float)): if not (crit1 <= value_to_check <= crit2): _LOG.error(f'`{keyname}` must be between {crit1} and {crit2} (input: {value_to_check}).') sys.exit(1) @@ -377,6 +377,7 @@ def check_options(self): """ Some sanity checks and corrections to the options. """ + if not (self.produce_bam or self.produce_vcf or self.produce_fastq): _LOG.error('No files would be produced, as all file types are set to false') sys.exit(1) @@ -438,16 +439,18 @@ def log_configuration(self): if self.parallel_mode == 'size': _LOG.info(f'Splitting reference into chunks.') - _LOG.info(f' - splitting input into size {self.size}') + _LOG.info(f' - splitting input into size {self.parallel_block_size}') elif self.parallel_mode == 'contig': _LOG.info(f'Splitting input by contig.') - if not self.cleanup_splits or self.reuse_splits: + if self.reuse_splits: splits_dir = Path(f'{self.output_dir}/splits/') - if splits_dir.is_dir(): + if not splits_dir.is_dir(): + raise FileNotFoundError(f"reuse_splits=True but splits dir not found: {splits_dir}") _LOG.info(f'Reusing existing splits {splits_dir}.') - else: - _LOG.warning(f'Reused splits set to True, but splits dir not found: {splits_dir}. Creating new splits') - _LOG.info(f'Preserving splits for next run in directory {self.splits_dir}.') + _LOG.info(f'Preserving splits for next run in directory {splits_dir}.') + elif not self.cleanup_splits: + splits_dir = Path(f'{self.output_dir}/splits/') + _LOG.info(f'Preserving splits for next run in directory {splits_dir}.') else: splits_dir = self.temp_dir_path / "splits" diff --git a/neat/read_simulator/utils/split_inputs.py b/neat/read_simulator/utils/split_inputs.py index 82f63cef..ad3a42f9 100644 --- a/neat/read_simulator/utils/split_inputs.py +++ b/neat/read_simulator/utils/split_inputs.py @@ -67,7 +67,7 @@ def main(options: Options, reference_index: dict) -> tuple[dict, int]: # We'll keep track of chunks by contig, to help us out later split_fasta_dict: dict[str, dict[tuple[int, int], Path]] = {key: {} for key in reference_index.keys()} for contig, seq_record in reference_index.items(): - if options.mode == "contig": + if options.parallel_mode == "contig": stem = f"{idx:0{pad}d}__{contig}" fa = options.splits_dir / f"{stem}.fa.gz" write_fasta(contig, seq_record.seq.upper(), fa) @@ -75,7 +75,7 @@ def main(options: Options, reference_index: dict) -> tuple[dict, int]: idx += 1 written += 1 else: - for start, subseq in chunk_record(seq_record.seq.upper(), options.size, overlap): + for start, subseq in chunk_record(seq_record.seq.upper(), options.parallel_block_size, overlap): stem = f"{idx:0{pad}d}__{contig}" fa = options.splits_dir / f"{stem}.fa.gz" write_fasta(contig, subseq, fa) diff --git a/tests/test_cli/test_cli.py b/tests/test_cli/test_cli.py index 07805497..9dce056a 100644 --- a/tests/test_cli/test_cli.py +++ b/tests/test_cli/test_cli.py @@ -9,6 +9,21 @@ from neat.cli.cli import Cli, main +def _write_min_cfg(tmp_path: Path) -> Path: + """ + Write a minimal config file for read-simulator. + + These CLI tests validate argument handling, logging, and return codes, + not the full simulation behavior, but read-simulator still requires -c. + """ + ref = tmp_path / "ref.fa" + ref.write_text(">chr1\nACGT\n", encoding="utf-8") + + cfg = tmp_path / "conf.yml" + cfg.write_text(f"reference: {ref}\nproduce_fastq: true\n", encoding="utf-8") + return cfg + + def test_cli_registers_read_simulator_subcommand(): cli = Cli() # Argparse stores subparsers in a private map; ensure our command is registered @@ -50,10 +65,15 @@ def test_logging_creates_named_log_file_and_announces(monkeypatch, tmp_path: Pat lambda *args, **kwargs: None, ) + cfg = _write_min_cfg(tmp_path) + rc = main(cli.parser, [ "--log-name", str(logname), # Supply a benign subcommand with minimal required args - "read-simulator", "-o", str(tmp_path), "-p", "pref" + "read-simulator", + "-c", str(cfg), + "-o", str(tmp_path), + "-p", "pref", ]) out = capsys.readouterr().out # main should create/log the file path and return 0 (success) @@ -68,13 +88,12 @@ def test_read_simulator_success_invokes_runner(monkeypatch, tmp_path: Path): called = {} def fake_runner(cfg, outdir, prefix): - called['args'] = (cfg, outdir, prefix) + called["args"] = (cfg, outdir, prefix) # Patch runner used by command monkeypatch.setattr("neat.cli.commands.read_simulator.read_simulator_runner", fake_runner) - cfg = tmp_path / "conf.yml" - cfg.write_text("reference: ''\n", encoding="utf-8") # minimal content; not validated here + cfg = _write_min_cfg(tmp_path) rc = main(cli.parser, [ "--no-log", @@ -85,7 +104,7 @@ def fake_runner(cfg, outdir, prefix): ]) assert rc == 0 - assert called['args'] == (str(cfg), str(tmp_path), "myprefix") + assert called["args"] == (str(cfg), str(tmp_path), "myprefix") def test_read_simulator_failure_returns_1_and_prints_error(monkeypatch, tmp_path: Path, capsys): @@ -94,11 +113,15 @@ def test_read_simulator_failure_returns_1_and_prints_error(monkeypatch, tmp_path def boom(*args, **kwargs): raise RuntimeError("kaboom") - monkeypatch.setattr("neat.read_simulator.read_simulator_runner", boom) + # Patch the runner symbol used by the read-simulator command + monkeypatch.setattr("neat.cli.commands.read_simulator.read_simulator_runner", boom) + + cfg = _write_min_cfg(tmp_path) rc = main(cli.parser, [ "--no-log", "read-simulator", + "-c", str(cfg), "-o", str(tmp_path), "-p", "x", ]) diff --git a/tests/test_read_simulator/test_options.py b/tests/test_read_simulator/test_options.py index a5528ab4..8f9c1e8a 100644 --- a/tests/test_read_simulator/test_options.py +++ b/tests/test_read_simulator/test_options.py @@ -1,6 +1,7 @@ from neat.read_simulator.utils.options import Options from pathlib import Path as _PathAlias +import logging as _logging import numpy as _np import textwrap as _textwrap import pytest as _pytest @@ -10,8 +11,42 @@ def _project_root() -> _PathAlias: return _PathAlias(__file__).resolve().parents[2] -# Redefine the function name used above to override the brittle test -# so pytest only sees this correct version. +@_pytest.fixture(autouse=True) +def _isolate_neat_logging(): + """ + Prevent flaky 'ValueError: I/O operation on closed file' logging errors under pytest. + """ + # Clear handlers on NEAT and all child loggers + for name, logger in list(_logging.Logger.manager.loggerDict.items()): + if name == "neat" or name.startswith("neat."): + if isinstance(logger, _logging.Logger): + for h in list(logger.handlers): + logger.removeHandler(h) + try: + h.close() + except Exception: + pass + logger.handlers.clear() + logger.propagate = True # child loggers will propagate to 'neat' + + neat_logger = _logging.getLogger("neat") + neat_logger.handlers.clear() + neat_logger.addHandler(_logging.NullHandler()) + neat_logger.propagate = False # stop at 'neat' (do not reach root) + + yield + + # Rremove NullHandler + for h in list(neat_logger.handlers): + neat_logger.removeHandler(h) + try: + h.close() + except Exception: + pass + neat_logger.handlers.clear() + neat_logger.propagate = True + + def test_basic_options(): reference = _project_root() / "data" / "H1N1.fa" base_options = Options(reference) @@ -57,7 +92,6 @@ def test_rng_seed_reproducible(): def test_from_cli_single_end_with_threads_and_splits(tmp_path: _PathAlias): - # Build a minimal YAML config using repository-relative paths cfg = _textwrap.dedent( f""" reference: {(_project_root() / 'data' / 'H1N1.fa').as_posix()} @@ -76,8 +110,8 @@ def test_from_cli_single_end_with_threads_and_splits(tmp_path: _PathAlias): rng_seed: 42 overwrite_output: true - mode: contig - size: 500000 + parallel_mode: size + parallel_block_size: 500000 threads: 2 cleanup_splits: false reuse_splits: false @@ -92,14 +126,12 @@ def test_from_cli_single_end_with_threads_and_splits(tmp_path: _PathAlias): opts = Options.from_cli(outdir, "fromcli", yml_path) - # Basics propagated assert opts.reference == _project_root() / "data" / "H1N1.fa" assert opts.read_len == 75 assert opts.coverage == 5 assert opts.ploidy == 2 assert opts.rng_seed == 42 - # Output construction via log_configuration() inside from_cli assert opts.output_dir == outdir assert opts.output_prefix == "fromcli" assert opts.fq1 == outdir / "fromcli.fastq.gz" @@ -107,9 +139,7 @@ def test_from_cli_single_end_with_threads_and_splits(tmp_path: _PathAlias): assert opts.bam is None assert opts.vcf is None - # Parallel-related settings assert opts.threads == 2 - # cleanup_splits: false -> splits dir under output_dir assert opts.splits_dir == outdir / "splits" assert opts.splits_dir.is_dir() @@ -132,7 +162,7 @@ def test_from_cli_paired_end_fragments(tmp_path: _PathAlias): rng_seed: 7 overwrite_output: true - mode: contig + parallel_mode: contig threads: 1 cleanup_splits: true reuse_splits: false @@ -162,6 +192,8 @@ def test_from_cli_reuse_splits_missing_dir_raises(tmp_path: _PathAlias): produce_bam: false produce_vcf: false threads: 4 + parallel_mode: size + parallel_block_size: 500000 cleanup_splits: true reuse_splits: true overwrite_output: true @@ -174,6 +206,5 @@ def test_from_cli_reuse_splits_missing_dir_raises(tmp_path: _PathAlias): outdir = tmp_path / "out" outdir.mkdir(parents=True, exist_ok=True) - options = Options.from_cli(outdir, "reuse", yml_path) - # should issue a warning but continue in this case - assert options.reuse_splits == True + with _pytest.raises(FileNotFoundError, match=r"reuse_splits=True"): + Options.from_cli(outdir, "reuse", yml_path) diff --git a/version.py b/version.py new file mode 100644 index 00000000..4b48e9d3 --- /dev/null +++ b/version.py @@ -0,0 +1,10 @@ +from importlib.metadata import PackageNotFoundError, version as _version + +def neat_version() -> str: + """ + Return NEAT's package version. + """ + try: + return _version("neat") + except PackageNotFoundError: + return "unknown"