diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 00000000..b13e9ce2 --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,26 @@ +# Minimal makefile for Sphinx documentation + +# You can set these variables from the command line. +SPHINXOPTS ?= -b html -W --keep-going +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = ./_build/ + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Clean build directory +clean: + rm -rf $(BUILDDIR) + +# Build HTML with automatic clean +html: clean + @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/README.md b/docs/README.md index c98fd5e3..a5ce4c40 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,17 +1,22 @@ # Documentation -To build the documentation locally: +To build the documentation, ensure you have all the necessary plugins installed: ```bash -# Install documentation dependencies pip install -e . --group docs +``` + +then run locally using `Make` (with working directory being the `docs/` directory): -# Clean any previous local runs first -rm -r ./docs/_build +```bash +make html +``` -# Build -sphinx-build -b html -W --keep-going docs ./docs/_build +or without `Make`: -# View the built documentation -# Open ./docs/_build/html/index.html in your favorite web browser +```bash +rm -rf _build +sphinx-build -b html -W --keep-going . _build ``` + +Then view the built documentation by opening `docs/_build/html/index.html` in your favorite web browser. diff --git a/docs/_static/custom.css b/docs/_static/custom.css new file mode 100644 index 00000000..6d470ca4 --- /dev/null +++ b/docs/_static/custom.css @@ -0,0 +1,9 @@ +/* The primary sphinx copybutton for code blocks. */ +button.copybtn { + opacity: 1; +} + +/* Confusingly, this is the identifier for the toggleprompt button. */ +span.copybutton { + opacity: 0; +} diff --git a/docs/conf.py b/docs/conf.py index 76ba5bf9..f9da1717 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,10 +1,10 @@ """Configuration file for the Sphinx documentation builder.""" +import pathlib import sys -from pathlib import Path # Add source directory to path for autodoc -sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "src")) +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "src")) # Project details project = "nwb2bids" @@ -18,6 +18,9 @@ "sphinx.ext.napoleon", "sphinx.ext.viewcode", "sphinx_tabs.tabs", + "sphinx_copybutton", + "sphinx_toggleprompt", # Used to control >>> behavior in the doctests + "myst_parser", # For including Markdown files to be rendered as RST ] # HTML configuration @@ -41,6 +44,9 @@ "doc_path": "docs", } +html_static_path = ["_static"] +html_css_files = ["custom.css"] + # Format signatures for better readability autodoc_typehints = "signature" autodoc_typehints_format = "short" @@ -53,9 +59,36 @@ # Disable sidebars for specific sections html_sidebars = { 'user_guide': [], + 'tutorials': [], "developer_guide": [], } +# Toggleprompt +toggleprompt_offset_right = 45 # This controls the position of the prompt (>>>) for the conversion gallery +toggleprompt_default_hidden = "true" + +# Copybutton +copybutton_exclude = '.linenos, .gp' # This avoids copying prompt (>>>) in the conversion gallery (issue #1465) + +# MyST +myst_enable_extensions = [ + "colon_fence", # ::: fences + "deflist", # Definition lists + "fieldlist", # Field lists + "html_admonition", # HTML-style admonitions + "html_image", # HTML images + "replacements", # Text replacements + "smartquotes", # Smart quotes + "strikethrough", # ~~strikethrough~~ + "substitution", # Variable substitutions + "tasklist", # Task lists +] +source_suffix = { + '.rst': 'restructuredtext', + '.md': 'markdown', +} + + # -------------------------------------------------- # Extension configuration # -------------------------------------------------- diff --git a/docs/conftest.py b/docs/conftest.py new file mode 100644 index 00000000..f53cb02a --- /dev/null +++ b/docs/conftest.py @@ -0,0 +1,29 @@ +"""Configuration file for the doctests.""" +import json +import pathlib +import typing + +import pytest + +import nwb2bids + + +# Doctest directories +@pytest.fixture(autouse=True) +def add_data_space(doctest_namespace: dict[str, typing.Any], tmp_path: pathlib.Path): + doctest_namespace["path_to_some_directory"] = pathlib.Path(tmp_path) + + nwb2bids.testing.generate_ephys_tutorial(mode="file") + nwb2bids.testing.generate_ephys_tutorial(mode="dataset") + + tutorial_directory = nwb2bids.testing.get_tutorial_directory() / "ephys_tutorial_file" + additional_metadata_file_path = tutorial_directory / "metadata.json" + + additional_metadata = { + "dataset_description": { + "Name": "My Custom BIDS Dataset", + "BIDSVersion": "1.8.0", + "Authors": ["First Last", "Second Author"] + } + } + additional_metadata_file_path.write_text(data=json.dumps(obj=additional_metadata)) diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst index 11526b81..d1b42ff3 100644 --- a/docs/developer_guide.rst +++ b/docs/developer_guide.rst @@ -105,16 +105,5 @@ TODO Documentation ------------- -The documentation is hosted on ReadTheDocs.org and can be built locally by first installing the ``docs`` group: - -.. code-block:: bash - - pip install -e ".[docs]" - -Then, from the root of the repository, run: - -.. code-block:: bash - - sphinx-build -b html -W --keep-going docs ./docs/_build/ - -And launch the resulting ``./docs/_build/index.html`` file in your web browser. +.. include:: README.md + :parser: myst_parser.sphinx_ diff --git a/docs/index.rst b/docs/index.rst index 0132a40c..afe3c6f7 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,6 +4,7 @@ :hidden: user_guide + tutorials developer_guide api/index diff --git a/docs/tutorials.rst b/docs/tutorials.rst new file mode 100644 index 00000000..347c67b0 --- /dev/null +++ b/docs/tutorials.rst @@ -0,0 +1,581 @@ + +.. _tutorials: + +Tutorials +========= + +**nwb2bids** comes pre-packaged with some demonstrative tools to help showcase its functionality on various types of +NWB file contents. No additional requirements, knowledge, or experience are necessary to run these tutorials! + +These tutorials demonstrates how to convert NWB file(s) into a BIDS directory structure. Each action allows for +alternative use cases based on the command line interface (CLI) or the Python library. + +All of the tutorials on this page will utilize the example NWB files produced from :ref:`generate-example-file` and +:ref:`generate-example-dataset`. You can, of course, feel free to utilize your own NWB files, but the resulting +output may appear different than what is shown through the generated examples. + + + +.. _generate-example-file: + +Generating an example NWB file +------------------------------ + +In case you don't have any NWB files handy, or perhaps you've never worked with NWB files before, you can generate +an example file by running: + +.. tabs:: + .. tab:: CLI + + .. code-block:: bash + + nwb2bids tutorial ephys file + + .. tab:: Python Library + + .. code-block:: python + + >>> import nwb2bids + >>> + >>> tutorial_file = nwb2bids.testing.generate_ephys_tutorial(mode="file") + + +This created an NWB file with contents typical of an extracellular electrophysiology experiment in your home +directory for **nwb2bids**: ``~/nwb2bids_tutorials/ephys_tutorial_file/ephys.nwb``. + +NWB files like these contain a lot of metadata about the probes and electrode structure, which will be useful later +when we compare the source file to the sidecar tables found in BIDS. You can explore these file contents through +`neurosift.app `_ by following `this link `_. + +.. tip:: + + The link above actually points to a similar file published on the DANDI Archive, which should be identical to + what was created on your system. You can explore and visualize your local file contents and structure + in many other ways, such as the + `NWB GUIDE `_, + `HDFView `_, or the + `PyNWB library `_. + + If you ever upload your own NWB files to DANDI or EMBER, you too can share Neurosift links with your collaborators! + + +.. _generate-example-dataset: + +Generating an example dataset +----------------------------- + +In case you don't have an entire experiment's worth of NWB files, you can generate an example dataset by running: + +.. tabs:: + .. tab:: CLI + + .. code-block:: bash + + nwb2bids tutorial ephys dataset + + .. tip:: + + Lost in the CLI? Can't remember the exact phrase to type when navigating through the command groups? No worries! + + Simply add the ``--help`` to any command to see detailed descriptions, expected inputs, and optional flags. + + You can also see a layout of the command structure by calling each level at a time, for example ``nwb2bids``, + ``nwb2bids tutorial``, and so on. + + Try it out on ``nwb2bids tutorial`` to see all the types of tutorial data we can generate for you. + + .. tab:: Python Library + + .. code-block:: python + + >>> import nwb2bids + >>> + >>> tutorial_directory = nwb2bids.testing.generate_ephys_tutorial( + ... mode="dataset" + ... ) + + .. tip:: + + Lost in the Python library? Can't remember the exact inputs to pass to an imported function? No worries! + + Simply add the ``?`` at the end of any module or function to see a detailed description of imports or + expected inputs. Try it out on ``nwb2bids?`` to see the library's welcome message. + + The **nwb2bids** library is also designed for easy auto-completion in interactive environments like + IPython or Jupyter notebooks; try it out on by hitting your tab button after typing ``nwb2bids.testing.`` to + see all the functions exposed to that submodule. + +Now we should have a directory that looks something like: + +.. code-block:: text + + ephys_tutorial_dataset/ + ├── ephys_session_3.nwb + ├── DO_NOT_CONVERT.nwb + └── some_sessions/ + ├── ephys_session_1.nwb + └── ephys_session_1.nwb + +.. note:: + + Don't like overwhelming your home directory with lots of small files? + + .. tabs:: + .. tab:: CLI + + You can control where tutorial data is generated by using the ``--output-directory`` flag (or ``-o`` for + short): + + .. code-block:: bash + + nwb2bids tutorial ephys file --output-directory path/to/some/directory + + .. tab:: Python Library + + You can control where tutorial data is generated by using the ``output_directory`` keyword argument: + + .. code-block:: python + + >>> import nwb2bids + >>> + >>> tutorial_directory = nwb2bids.testing.generate_ephys_tutorial( + ... mode="file", + ... output_directory=path_to_some_directory, + ... ) + + + +.. _tutorial-single-file: + +Tutorial 1 - Converting a single file +------------------------------------- + +To convert a single NWB file to BIDS dataset structure, we run the following command: + +.. tabs:: + .. tab:: CLI + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_file + + nwb2bids convert ephys.nwb --bids-directory bids_dataset_1 + + .. tab:: Python Library + + .. code-block:: python + + >>> import pathlib + >>> + >>> import nwb2bids + >>> + >>> tutorial_directory = pathlib.Path.home() / "nwb2bids_tutorials/ephys_tutorial_file" + >>> nwb_paths = [tutorial_directory / "ephys.nwb"] + >>> bids_directory = tutorial_directory / "bids_dataset_1" + >>> bids_directory.mkdir(exist_ok=True) + >>> + >>> run_config = nwb2bids.RunConfig(bids_directory=bids_directory) + >>> converter = nwb2bids.convert_nwb_dataset( + ... nwb_paths=nwb_paths, + ... run_config=run_config, + ... ) + +Notice how we explicitly specified the output BIDS directory in the previous step. We will cover the implicit +(current working directory) approach in :ref:`tutorial-implicit-bids-directory`. + +You can now explore the directory structure and contents of the generated BIDS dataset. You should see something +along the lines of: + +.. code-block:: text + + ephys_tutorial_file/bids_dataset_1/ + ├── dataset_description.json + ├── participants.tsv + ├── participants.json + └── sub-001/ + ├── sub-001_sessions.tsv + ├── sub-001_sessions.json + └── ses-A/ + └── ecephys/ + ├── sub-001_ses-A_ecephys.nwb + ├── sub-001_ses-A_channels.tsv + ├── sub-001_ses-A_channels.json + ├── sub-001_ses-A_electrodes.tsv + ├── sub-001_ses-A_electrodes.json + ├── sub-001_ses-A_probes.tsv + └── sub-001_ses-A_probes.json + + + +.. _tutorial-multiple-files: + +Tutorial 2 - Converting directories +----------------------------------- + +To convert all of the NWB files under a directory to BIDS, we can run the following command: + +.. tabs:: + .. tab:: CLI + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_dataset + + nwb2bids convert some_sessions --bids-directory bids_dataset_2 + + .. tab:: Python Library + + .. code-block:: python + + >>> import pathlib + >>> + >>> import nwb2bids + >>> + >>> tutorial_directory = pathlib.Path.home() / "nwb2bids_tutorials/ephys_tutorial_dataset" + >>> nwb_paths = [tutorial_directory / "some_sessions"] + >>> bids_directory = tutorial_directory / "bids_dataset_2" + >>> bids_directory.mkdir(exist_ok=True) + >>> + >>> run_config = nwb2bids.RunConfig(bids_directory=bids_directory) + >>> converter = nwb2bids.convert_nwb_dataset( + ... nwb_paths=nwb_paths, + ... run_config=run_config, + ... ) + +And our BIDS dataset should look like: + +.. code-block:: text + + ephys_tutorial_dataset/bids_dataset_2/ + ├── dataset_description.json + ├── participants.tsv + ├── participants.json + └── sub-001/ + ├── sub-001_sessions.tsv + ├── sub-001_sessions.json + ├── ses-A/ + │ └── ecephys/ + │ ├── sub-001_ses-A_ecephys.nwb + │ ├── sub-001_ses-A_channels.tsv + │ ├── sub-001_ses-A_channels.json + │ ├── sub-001_ses-A_electrodes.tsv + │ ├── sub-001_ses-A_electrodes.json + │ ├── sub-001_ses-A_probes.tsv + │ └── sub-001_ses-A_probes.json + └── ses-B/ + └── ecephys/ + ├── sub-001_ses-B_ecephys.nwb + ├── sub-001_ses-B_channels.tsv + ├── sub-001_ses-B_channels.json + ├── sub-001_ses-B_electrodes.tsv + ├── sub-001_ses-B_electrodes.json + ├── sub-001_ses-B_probes.tsv + └── sub-001_ses-B_probes.json + + +.. tip :: + + BIDS recommends certain best practices for deciding on high-quality labels for entities such as subjects and sessions. + + **nwb2bids** simply extracts these values from the NWB contents, so it is a good idea to ensure that these values + are appropriate prior to conversion to BIDS. + + Read more about `Common Principles: Filesystem structure `_. + + In particular, see the section about `richness versus distinctness + `_. + + + +.. _tutorial-multiple-inputs: + +Tutorial 3 - Multiple inputs +---------------------------- + +Using the example dataset generated in :ref:`generate-example-dataset`, we will now show how to convert any mix of +individual NWB files and directories containing NWB files. + +Attentive readers may have noticed that in :ref:`tutorial-multiple-files`, we only converted the files under +``some_sessions/`` while ignoring the other NWB file at the top level (including the one called ``DO_NOT_CONVERT.nwb``). + +We can select which files and directories to convert like so: + +.. tabs:: + .. tab:: CLI + + .. tabs:: + .. tab:: Unix / macOS + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_dataset + + nwb2bids convert ephys_session_3.nwb some_sessions \ + --bids-directory bids_dataset_3 + + .. tab:: Windows + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_dataset + + nwb2bids convert ephys_session_3.nwb some_sessions ^ + --bids-directory bids_dataset_3 + + The command line can take any number of inputs (separated by spaces) prior to other flags such as + ``--bids-directory``. Shell globs, such as ``*.nwb``, could be used as shown in + section :ref:`tutorial-implicit-bids-directory`. These inputs can be any mix of files or directories. + + .. tab:: Python Library + + .. code-block:: python + + >>> import pathlib + >>> + >>> import nwb2bids + >>> + >>> tutorial_directory = pathlib.Path.home() / "nwb2bids_tutorials/ephys_tutorial_dataset" + >>> nwb_paths = [ + ... tutorial_directory / "ephys_session_3.nwb", + ... tutorial_directory / "some_sessions", + ... ] + >>> bids_directory = tutorial_directory / "bids_dataset_3" + >>> bids_directory.mkdir(exist_ok=True) + >>> + >>> run_config = nwb2bids.RunConfig(bids_directory=bids_directory) + >>> converter = nwb2bids.convert_nwb_dataset( + ... nwb_paths=nwb_paths, + ... run_config=run_config, + ... ) + +Our resulting BIDS dataset should now contain all three NWB files converted to BIDS: + +.. code-block:: text + + ephys_tutorial_dataset/bids_dataset_3/ + ├── dataset_description.json + ├── participants.tsv + ├── participants.json + ├── sub-001/ + │ ├── sub-001_sessions.tsv + │ ├── sub-001_sessions.json + │ ├── ses-A/ + │ │ └── ecephys/ + │ │ ├── sub-001_ses-A_ecephys.nwb + │ │ ├── sub-001_ses-A_channels.tsv + │ │ ├── sub-001_ses-A_channels.json + │ │ ├── sub-001_ses-A_electrodes.tsv + │ │ ├── sub-001_ses-A_electrodes.json + │ │ ├── sub-001_ses-A_probes.tsv + │ │ └── sub-001_ses-A_probes.json + │ └── ses-B/ + │ └── ecephys/ + │ ├── sub-001_ses-B_ecephys.nwb + │ ├── sub-001_ses-B_channels.tsv + │ ├── sub-001_ses-B_channels.json + │ ├── sub-001_ses-B_electrodes.tsv + │ ├── sub-001_ses-B_electrodes.json + │ ├── sub-001_ses-B_probes.tsv + │ └── sub-001_ses-B_probes.json + └── sub-002/ + ├── sub-002_sessions.tsv + ├── sub-002_sessions.json + └── ses-C/ + └── ecephys/ + ├── sub-002_ses-C_ecephys.nwb + ├── sub-002_ses-C_channels.tsv + ├── sub-002_ses-C_channels.json + ├── sub-002_ses-C_electrodes.tsv + ├── sub-002_ses-C_electrodes.json + ├── sub-002_ses-C_probes.tsv + └── sub-002_ses-C_probes.json + + + +.. _tutorial-implicit-bids-directory: + +Tutorial 4 - Implicit BIDS directory +------------------------------------ + +In the previous tutorials we have always specified the target BIDS directory as an explicit path. It is also possible +to use the current working directory as the BIDS directory, provided that it is either empty or already a partial BIDS +dataset as defined by a ``dataset_description.json`` file with a ``BIDSVersion`` value specified within. + +To test this out, we can create a new empty directory and navigate into it before converting: + +.. tabs:: + .. tab:: CLI + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_dataset/ + mkdir bids_dataset_4 + cd bids_dataset_4 + + nwb2bids convert ../ephys_session_3.nwb ../some_sessions/*.nwb + + The command line can take any number of inputs (separated by spaces) prior to other flags such as + ``--bids-directory``. These inputs can be any mix of files or directories. + + .. tab:: Python Library + + .. code-block:: python + + >>> import os + >>> import pathlib + >>> + >>> import nwb2bids + >>> + >>> tutorial_directory = pathlib.Path.home() / "nwb2bids_tutorials/ephys_tutorial_dataset" + >>> nwb_paths = [ + ... tutorial_directory / "ephys_session_3.nwb", + ... tutorial_directory / "some_sessions", + ... ] + >>> bids_directory = tutorial_directory / "bids_dataset_4" + >>> bids_directory.mkdir(exist_ok=True) + >>> os.chdir(path=bids_directory) + >>> + >>> converter = nwb2bids.convert_nwb_dataset(nwb_paths=nwb_paths) + + +And the results should match what we saw at the end of :ref:`tutorial-multiple-inputs`, except that we operated +entirely from within ``bids_dataset_4``. + + + +.. _tutorial-additional-metadata: + +Tutorial 5 - Additional metadata +--------------------------------- + +NWB files don't always include all the little metadata details you might want to include in BIDS. To include manual +metadata that is absent in the source files, you can provide additional metadata through a simple JSON +structure shown below. + +.. note:: + + Currently only ``dataset_description`` metadata is supported, but more detailed subject- and session-level + metadata will be supported in the future. + +To show how such additional metadata can be included through **nwb2bids**, start by creating a file named +``metadata.json`` inside the ``ephys_tutorial_dataset`` directory we used in :ref:`tutorial-multiple-files`. +Then fill the contents of the file to match below: + +.. code-block:: json + + { + "dataset_description": { + "Name": "My Custom BIDS Dataset", + "BIDSVersion": "1.8.0", + "Authors": ["Last, First"] + } + } + +To include this additional metadata during conversion, we can use the following commands: + +.. tabs:: + .. tab:: CLI + + .. tabs:: + .. tab:: Unix / macOS + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_file + + nwb2bids convert ephys.nwb \ + --bids-directory bids_dataset_5 \ + --additional-metadata-file-path metadata.json + + .. tab:: Windows + + .. code-block:: bash + + cd ~/nwb2bids_tutorials/ephys_tutorial_file + + nwb2bids convert ephys.nwb ^ + --bids-directory bids_dataset_5 ^ + --additional-metadata-file-path metadata.json + + .. tab:: Python Library + + .. code-block:: python + + >>> import pathlib + >>> + >>> import nwb2bids + >>> + >>> tutorial_directory = pathlib.Path.home() / "nwb2bids_tutorials/ephys_tutorial_file" + >>> nwb_paths = [tutorial_directory / "ephys.nwb"] + >>> bids_directory = tutorial_directory / "bids_dataset_5" + >>> bids_directory.mkdir(exist_ok=True) + >>> additional_metadata_file_path = tutorial_directory / "metadata.json" + >>> + >>> run_config = nwb2bids.RunConfig( + ... bids_directory=bids_directory, + ... additional_metadata_file_path=additional_metadata_file_path, + ... ) + >>> converter = nwb2bids.convert_nwb_dataset( + ... nwb_paths=nwb_paths, + ... run_config=run_config, + ... ) + +Our resulting bids `dataset_description.json` now includes our additional metadata! + + +.. _tutorial-library-customization: + +Tutorial 6 - Library customization +---------------------------------- + +The **nwb2bids** Python library is much easier to customize and interact with than the CLI usage. + +The workflow that is automatically run by the helper function :func:`~nwb2bids.convert_nwb_dataset()` can be +broken down into the following distinct steps: + +.. code-block:: python + + >>> import pathlib + >>> import nwb2bids + >>> + >>> tutorial_directory = pathlib.Path.home() / "nwb2bids_tutorials/ephys_tutorial_file" + >>> nwb_paths = [tutorial_directory / "ephys.nwb"] + >>> bids_directory = tutorial_directory / "bids_dataset_6" + >>> bids_directory.mkdir(exist_ok=True) + >>> additional_metadata_file_path = tutorial_directory / "metadata.json" + >>> + >>> # Step 1: Initialize the DatasetConverter object + >>> run_config = nwb2bids.RunConfig( + ... bids_directory=bids_directory, + ... additional_metadata_file_path=additional_metadata_file_path, + ... ) + >>> converter = nwb2bids.DatasetConverter.from_nwb_paths( + ... nwb_paths=nwb_paths, + ... run_config=run_config, + ... ) + >>> + >>> # Step 2: Extract metadata from NWB contents + >>> converter.extract_metadata() + >>> + >>> # Step 3: Convert NWB files to BIDS structure + >>> converter.convert_to_bids_dataset() + +The ``converter`` object (a type of :class:`~nwb2bids.DatasetConverter`) exposes many useful attributes and methods +that may useful to explore. In particular, it contains all of the :class:`~nwb2bids.SessionConverter` objects that were +assembled from the input NWB files. These in turn attach various metadata models, +such as :class:`~nwb2bids.bids_models.BidsSessionMetadata`, from which all metadata attributes used during the BIDS +conversion process may be inspected and modified prior to writing the resulting files. + + + +.. _tutorial-future: + +More tutorials coming soon! +--------------------------- + +We are still compiling some detailed tutorials for more advanced use cases, such as sanitization, run configurations, +non-ephys data types, and more! + +Check back soon for updates. diff --git a/pyproject.toml b/pyproject.toml index 2151a497..4f3de21c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -125,6 +125,10 @@ docs = [ "sphinx<9.0.0", "sphinx-tabs", "pydata-sphinx-theme", + "sphinx-copybutton", + "sphinx-toggleprompt", + "myst-parser", + "pytest", # For doctest integration ] dev-all = [ {include-group = "test"}, @@ -195,3 +199,6 @@ known-first-party = ["nwb2bids"] markers = [ "remote: mark a test as requiring remote resources" ] +addopts = "-ra --doctest-glob='*.rst'" +testpaths = ["docs", "tests"] +doctest_optionflags = "ELLIPSIS" diff --git a/src/nwb2bids/_command_line_interface/_main.py b/src/nwb2bids/_command_line_interface/_main.py index b299ff36..f837ad9d 100644 --- a/src/nwb2bids/_command_line_interface/_main.py +++ b/src/nwb2bids/_command_line_interface/_main.py @@ -7,6 +7,7 @@ from .._core._convert_nwb_dataset import convert_nwb_dataset from .._inspection._inspection_result import Severity from .._tools._pluralize import _pluralize +from ..testing import generate_ephys_tutorial # nwb2bids @@ -125,6 +126,54 @@ def _run_convert_nwb_dataset( not_any_failures = not any(notification.severity == Severity.ERROR for notification in notifications) if not_any_failures and not silent: - text = "BIDS dataset was successfully created!" + text = "\nBIDS dataset was successfully created!\n\n" console_notification = rich_click.style(text=text, fg="green") rich_click.echo(message=console_notification) + + +# nwb2bids tutorial +@_nwb2bids_cli.group(name="tutorial") +def _nwb2bids_tutorial_cli(): + pass + + +# nwb2bids tutorial ephys +@_nwb2bids_tutorial_cli.group(name="ephys") +def _nwb2bids_tutorial_ephys_cli(): + pass + + +# nwb2bids tutorial ephys file +@_nwb2bids_tutorial_ephys_cli.command(name="file") +@rich_click.option( + "--output-directory", + "-o", + help="Path to the folder where the tutorial file(s) will be created (default: user home directory).", + required=False, + type=rich_click.Path(writable=True), + default=None, +) +def _nwb2bids_tutorial_ephys_file_cli(output_directory: str | None = None) -> None: + file_path = generate_ephys_tutorial(output_directory=output_directory, mode="file") + + text = f"\nAn example NWB file has been created at: {file_path}\n\n" + message = rich_click.style(text=text, fg="green") + rich_click.echo(message=message) + + +# nwb2bids tutorial ephys dataset +@_nwb2bids_tutorial_ephys_cli.command(name="dataset") +@rich_click.option( + "--output-directory", + "-o", + help="Path to the folder where the tutorial files will be created (default: user home directory).", + required=False, + type=rich_click.Path(writable=True), + default=None, +) +def _nwb2bids_tutorial_ephys_dataset_cli(output_directory: str | None = None) -> None: + tutorial_directory = generate_ephys_tutorial(output_directory=output_directory, mode="dataset") + + text = f"\nAn example NWB dataset has been created at: {tutorial_directory}\n\n" + message = rich_click.style(text=text, fg="green") + rich_click.echo(message=message) diff --git a/src/nwb2bids/_converters/_run_config.py b/src/nwb2bids/_converters/_run_config.py index 4b9e28e7..b9b89602 100644 --- a/src/nwb2bids/_converters/_run_config.py +++ b/src/nwb2bids/_converters/_run_config.py @@ -4,7 +4,7 @@ import pydantic from .._core._file_mode import _determine_file_mode -from .._core._home import _get_home_directory +from .._core._home import _get_nwb2bids_home_directory from .._core._run_id import _generate_run_id from .._core._validate_existing_bids import _validate_bids_directory @@ -45,7 +45,9 @@ class RunConfig(pydantic.BaseModel): file_mode: typing.Annotated[ typing.Literal["move", "copy", "symlink"], pydantic.Field(default_factory=_determine_file_mode) ] - cache_directory: typing.Annotated[pydantic.DirectoryPath, pydantic.Field(default_factory=_get_home_directory)] + cache_directory: typing.Annotated[ + pydantic.DirectoryPath, pydantic.Field(default_factory=_get_nwb2bids_home_directory) + ] run_id: typing.Annotated[str, pydantic.Field(default_factory=_generate_run_id)] _parent_run_directory: pathlib.Path = pydantic.PrivateAttr() _run_directory: pathlib.Path = pydantic.PrivateAttr() diff --git a/src/nwb2bids/_core/_home.py b/src/nwb2bids/_core/_home.py index 6bb8e7d9..eed0b98b 100644 --- a/src/nwb2bids/_core/_home.py +++ b/src/nwb2bids/_core/_home.py @@ -1,7 +1,7 @@ import pathlib -def _get_home_directory() -> pathlib.Path: +def _get_nwb2bids_home_directory() -> pathlib.Path: """ Get the home directory used by the `nwb2bids` project. diff --git a/src/nwb2bids/testing/__init__.py b/src/nwb2bids/testing/__init__.py index 0ccedde0..8bf6431d 100644 --- a/src/nwb2bids/testing/__init__.py +++ b/src/nwb2bids/testing/__init__.py @@ -1,10 +1,14 @@ from ._assert_subdirectory_structure import assert_subdirectory_structure from ._mocks._mock_neurodata_objects import mock_time_intervals, mock_epochs_table, mock_trials_table from ._create_file_tree import create_file_tree +from ._mocks._tutorials import generate_ephys_tutorial +from ._mocks._tutorials import get_tutorial_directory __all__ = [ "assert_subdirectory_structure", "create_file_tree", + "get_tutorial_directory", + "generate_ephys_tutorial", "mock_epochs_table", "mock_time_intervals", "mock_trials_table", diff --git a/src/nwb2bids/testing/_mocks/_tutorials.py b/src/nwb2bids/testing/_mocks/_tutorials.py new file mode 100644 index 00000000..85f84ab3 --- /dev/null +++ b/src/nwb2bids/testing/_mocks/_tutorials.py @@ -0,0 +1,84 @@ +import pathlib +import typing + +import pydantic +import pynwb +import pynwb.testing.mock.ecephys +import pynwb.testing.mock.file + + +def get_tutorial_directory() -> pathlib.Path: + tutorial_dir = pathlib.Path.home() / "nwb2bids_tutorials" + tutorial_dir.mkdir(exist_ok=True) + return tutorial_dir + + +def _generate_ecephys_file(*, nwbfile_path: pathlib.Path, subject_id: str = "001", session_id: str = "A") -> None: + nwbfile = pynwb.testing.mock.file.mock_NWBFile( + session_id=session_id, + session_description="An example NWB file containing ephys neurodata types - for use in the nwb2bids tutorials.", + ) + + subject = pynwb.file.Subject( + subject_id=subject_id, + species="Mus musculus", + sex="M", + ) + nwbfile.subject = subject + + pynwb.testing.mock.ecephys.mock_ElectricalSeries( + name="ExampleElectricalSeries", + description=( + "An example electrical series that represents data which could have been " + "read off of the channels of an ephys probe." + ), + nwbfile=nwbfile, + ) + + with pynwb.NWBHDF5IO(path=nwbfile_path, mode="w") as file_stream: + file_stream.write(nwbfile) + + +@pydantic.validate_call +def generate_ephys_tutorial( + *, mode=typing.Literal["file", "dataset"], output_directory: pydantic.DirectoryPath | None = None +) -> pathlib.Path: + if output_directory is None: + tutorial_dir = get_tutorial_directory() + output_directory = tutorial_dir / f"ephys_tutorial_{mode}" + output_directory.mkdir(exist_ok=True) + + if mode == "file": + nwbfile_path = output_directory / "ephys.nwb" + _generate_ecephys_file(nwbfile_path=nwbfile_path) + + return nwbfile_path + elif mode == "dataset": + subdir = output_directory / "some_sessions" + subdir.mkdir(exist_ok=True) + index_to_paths = { + 0: output_directory / subdir / "ephys_session_1.nwb", + 1: output_directory / subdir / "ephys_session_2.nwb", + 2: output_directory / "ephys_session_3.nwb", + 3: output_directory / "DO_NOT_CONVERT.nwb", + } + index_to_subject_id = { + 0: "001", + 1: "001", + 2: "002", + 3: "003", + } + index_to_session_id = { + 0: "A", + 1: "B", + 2: "C", + 3: "D", + } + + for index in range(4): + nwbfile_path = index_to_paths[index] + _generate_ecephys_file( + nwbfile_path=nwbfile_path, subject_id=index_to_subject_id[index], session_id=index_to_session_id[index] + ) + + return output_directory diff --git a/tests/integration/test_convert_nwb_dataset.py b/tests/integration/test_convert_nwb_dataset.py index e13243a5..08ea2d74 100644 --- a/tests/integration/test_convert_nwb_dataset.py +++ b/tests/integration/test_convert_nwb_dataset.py @@ -2,6 +2,8 @@ import pathlib +import pytest + import nwb2bids @@ -124,32 +126,32 @@ def test_ecephys_convert_nwb_dataset(ecephys_nwbfile_path: pathlib.Path, tempora ) -def test_optional_bids_directory( - minimal_nwbfile_path: pathlib.Path, temporary_bids_directory: pathlib.Path, monkeypatch +def test_implicit_bids_directory( + minimal_nwbfile_path: pathlib.Path, temporary_bids_directory: pathlib.Path, monkeypatch: pytest.MonkeyPatch ): - new_bids_directory = temporary_bids_directory / "bids" + implicit_bids_directory = temporary_bids_directory / "test_convert_nwb_dataset_implicit_bids" monkeypatch.chdir(temporary_bids_directory) nwb_paths = [minimal_nwbfile_path] nwb2bids.convert_nwb_dataset(nwb_paths=nwb_paths) expected_structure = { - new_bids_directory: { + implicit_bids_directory: { "directories": {"sub-123"}, "files": {"dataset_description.json", "participants.json", "participants.tsv"}, }, - new_bids_directory + implicit_bids_directory / "sub-123": { "directories": {"ses-456"}, "files": {"sub-123_sessions.json", "sub-123_sessions.tsv"}, }, - new_bids_directory + implicit_bids_directory / "sub-123" / "ses-456": { "directories": {"ecephys"}, "files": set(), }, - new_bids_directory + implicit_bids_directory / "sub-123" / "ses-456" / "ecephys": { @@ -159,4 +161,6 @@ def test_optional_bids_directory( }, }, } - nwb2bids.testing.assert_subdirectory_structure(directory=new_bids_directory, expected_structure=expected_structure) + nwb2bids.testing.assert_subdirectory_structure( + directory=implicit_bids_directory, expected_structure=expected_structure + ) diff --git a/tests/unit/test_directory_conditions.py b/tests/unit/test_directory_conditions.py index 28233eeb..f2e4cdc1 100644 --- a/tests/unit/test_directory_conditions.py +++ b/tests/unit/test_directory_conditions.py @@ -81,7 +81,7 @@ def test_disallowed_directory_conditions( minimal_nwbfile_path: pathlib.Path, temporary_bids_directory: pathlib.Path, additional_metadata_file_path: pathlib.Path, - monkeypatch, + monkeypatch: pytest.MonkeyPatch, ): dataset_description_file_path = temporary_bids_directory / "dataset_description.json" diff --git a/tests/unit/test_tutorial_helpers.py b/tests/unit/test_tutorial_helpers.py new file mode 100644 index 00000000..0b2204ba --- /dev/null +++ b/tests/unit/test_tutorial_helpers.py @@ -0,0 +1,12 @@ +import pathlib + +import py.path + +import nwb2bids + + +def test_ephys_tutorial_generation(tmpdir: py.path.local): + tutorial_path = nwb2bids.testing.generate_ephys_tutorial(output_directory=pathlib.Path(tmpdir), mode="file") + + assert tutorial_path.exists() + assert tutorial_path.suffix == ".nwb"