Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split install and configure commands #38

Merged
merged 16 commits into from
Jul 10, 2020
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .github/codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
codecov:
require_ci_to_pass: yes

coverage:
precision: 1
round: down
range: "70...100"
status:
project: # Coverage of whole project
default:
target: auto # Coverage target to pass; auto is base commit
threshold: 2% # Allow coverage to drop by this much vs. base and still pass
patch: # Coverage of lines in this change
default:
target: 80% # Coverage target to pass
threshold: 20% # Allow coverage to drop by this much vs. base and still pass


comment:
layout: "diff,flags,tree"
12 changes: 8 additions & 4 deletions nbautoexport/jupyter_config.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from inspect import getsourcelines
from pathlib import Path
from pkg_resources import parse_version
from packaging.version import parse as parse_version
import re
import textwrap
from typing import Optional

from jupyter_core.paths import jupyter_config_dir
from traitlets.config.loader import Config
Expand Down Expand Up @@ -42,11 +43,14 @@ def _post_save(model, os_path, contents_manager):
version_regex = re.compile(r"(?<=# >>> nbautoexport initialize, version=\[).*(?=\] >>>)")


def install_post_save_hook():
def install_post_save_hook(config_path: Optional[Path] = None):
"""Splices the post save hook into the global Jupyter configuration file
"""
config_dir = jupyter_config_dir()
config_path = (Path(config_dir) / "jupyter_notebook_config.py").expanduser().resolve()
if config_path is None:
config_dir = jupyter_config_dir()
config_path = Path(config_dir) / "jupyter_notebook_config.py"

config_path = config_path.expanduser().resolve()

if not config_path.exists():
logger.debug(f"No existing Jupyter configuration detected at {config_path}. Creating...")
Expand Down
91 changes: 83 additions & 8 deletions nbautoexport/nbautoexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@
from pathlib import Path
from typing import List, Optional

from jupyter_core.paths import jupyter_config_dir
from packaging.version import parse as parse_version
import typer

from nbautoexport.clean import find_files_to_clean
from nbautoexport.export import export_notebook
from nbautoexport.jupyter_config import install_post_save_hook
from nbautoexport.jupyter_config import block_regex, install_post_save_hook, version_regex
from nbautoexport.sentinel import (
DEFAULT_CLEAN,
DEFAULT_EXPORT_FORMATS,
Expand Down Expand Up @@ -44,10 +46,18 @@ def main(
help="Show nbautoexport version.",
),
):
"""Exports Jupyter notebooks to various file formats (.py, .html, and more) upon save,
automatically.
"""Automatically export Jupyter notebooks to various file formats (.py, .html, and more) upon
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah just noticing that typer very nicely adjusts the width of these docstrings when they are printed out in the command line. Now the docstrings can be written with column width of 100 in the source code, but printed out to column width of 80 for the 0.1% of ppl who use ancient monitors.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this actually annoyed me recently for a different project where I wanted to put some ASCII art in my help string but typer totally destroyed it with formatting. 😂 😢

save.

Use the install command to configure a notebooks directory to be watched.
To set up, first use the 'install' command to register nbautoexport with Jupyter. If you
already have a Jupyter server running, you will need to restart it.

Next, you will need to use the 'configure' command to create a .nbautoexport configuration file
in the same directory as the notebooks you want to have export automatically.

Once nbautoexport is installed with the first step, exporting will run automatically when
saving a notebook in Jupyter for any notebook where there is a .nbautoexport configuration file
in the same directory.
"""
pass

Expand Down Expand Up @@ -192,13 +202,45 @@ def export(

@app.command()
def install(
jupyter_config: Optional[Path] = typer.Option(
None,
exists=False,
file_okay=True,
dir_okay=False,
writable=True,
help=(
"Path to config file. If not specified (default), will determine appropriate path "
"used by Jupyter. You should only specify this option if you use a nonstandard config "
"file path that you explicitly pass to Jupyter with the --config option at startup."
),
)
):
"""Register nbautoexport post-save hook with Jupyter. Note that if you already have a Jupyter
server running, you will need to restart in order for it to take effect. This is a one-time
installation.

This works by adding an initialization block in your Jupyter config file that will register
nbautoexport's post-save function. If an nbautoexport initialization block already exists and
is from an older version of nbautoexport, this command will replace it with an updated version.
"""
install_post_save_hook(config_path=jupyter_config)

typer.echo("nbautoexport post-save hook successfully installed with Jupyter.")
typer.echo(
"If a Jupyter server is already running, you will need to restart it for nbautoexport "
"to work."
)


@app.command()
def configure(
directory: Path = typer.Argument(
"notebooks",
exists=True,
file_okay=False,
dir_okay=True,
writable=True,
help="Path to directory of notebook files to watch with nbautoexport.",
help="Path to directory of notebook files to configure with nbautoexport.",
),
export_formats: List[ExportFormat] = typer.Option(
DEFAULT_EXPORT_FORMATS,
Expand Down Expand Up @@ -238,15 +280,48 @@ def install(
),
):
"""
Create a .nbautoexport configuration file in DIRECTORY. Defaults to "./notebooks/"
Create a .nbautoexport configuration file in a directory. If nbautoexport has been installed
with the 'install' command, then Jupyter will automatically export any notebooks on save that
are in the same directory as the .nbautoexport file.

An .nbautoexport configuration file only applies to that directory, nonrecursively. You must
independently configure other directories containing notebooks.
"""
if verbose:
logging.basicConfig(level=logging.DEBUG)

config = NbAutoexportConfig(
export_formats=export_formats, organize_by=organize_by, clean=clean
)
try:
install_sentinel(export_formats, organize_by, directory, overwrite)
install_sentinel(directory=directory, config=config, overwrite=overwrite)
except FileExistsError as msg:
typer.echo(msg)
raise typer.Exit(code=1)

install_post_save_hook()
# Check for installation in Jupyter config
installed = False
jupyter_config_file = (
(Path(jupyter_config_dir()) / "jupyter_notebook_config.py").expanduser().resolve()
)
if jupyter_config_file.exists():
with jupyter_config_file.open("r") as fp:
jupyter_config_text = fp.read()
if block_regex.search(jupyter_config_text):
installed = True
version_match = version_regex.search(jupyter_config_text)
if version_match:
existing_version = version_match.group()
else:
existing_version = ""

if parse_version(existing_version) < parse_version(__version__):
typer.echo(
"Warning: nbautoexport initialize is an older version. "
"Please run 'install' command to update."
)
if not installed:
typer.echo(
"Warning: nbautoexport is not properly installed with Jupyter. "
"Please run 'install' command."
)
12 changes: 1 addition & 11 deletions nbautoexport/sentinel.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,16 +43,8 @@ class Config:
extra = "forbid"


def install_sentinel(
export_formats: List[ExportFormat], organize_by: OrganizeBy, directory: Path, overwrite: bool
):
def install_sentinel(directory: Path, config: NbAutoexportConfig, overwrite: bool):
"""Writes the configuration file to a specified directory.

Args:
export_formats: A list of `nbconvert`-supported export formats to write on each save
organize_by: Whether to organize exported files by notebook filename or in folders by extension
directory: The directory containing the notebooks to monitor
overwrite: Overwrite an existing sentinel file if one exists
"""
sentinel_path = directory / SAVE_PROGRESS_INDICATOR_FILE

Expand All @@ -62,8 +54,6 @@ def install_sentinel(
"""If you wish to overwrite, use the --overwrite flag."""
)
else:
config = NbAutoexportConfig(export_formats=export_formats, organize_by=organize_by)

logger.info(f"Creating configuration file at {sentinel_path}")
logger.info(f"\n{config.json(indent=2)}")
with sentinel_path.open("w") as fp:
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
jupyter-contrib-nbextensions>=0.5.1
nbconvert>=5.6.1
packaging
pydantic
typer>=0.3.0
6 changes: 3 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@ def test_main():
runner = CliRunner()
result = runner.invoke(app)
assert result.exit_code == 0
assert "Exports Jupyter notebooks to various file formats" in result.output
assert "Automatically export Jupyter notebooks to various file formats" in result.output


def test_main_help():
"""Test the CLI main callback with --help flag."""
runner = CliRunner()
result = runner.invoke(app, ["--help"])
assert result.exit_code == 0
assert "Exports Jupyter notebooks to various file formats" in result.output
assert "Automatically export Jupyter notebooks to various file formats" in result.output


def test_version():
Expand All @@ -38,7 +38,7 @@ def test_main_python_m():
universal_newlines=True,
)
assert result.returncode == 0
assert "Exports Jupyter notebooks to various file formats" in result.stdout
assert "Automatically export Jupyter notebooks to various file formats" in result.stdout
assert result.stdout.startswith("Usage: python -m nbautoexport")
assert "Usage: __main__.py" not in result.stdout

Expand Down
153 changes: 153 additions & 0 deletions tests/test_cli_configure.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"""Tests for `nbautoexport` package."""

import json

from typer.testing import CliRunner

from nbautoexport import jupyter_config, nbautoexport
from nbautoexport.nbautoexport import app
from nbautoexport.sentinel import (
DEFAULT_CLEAN,
DEFAULT_EXPORT_FORMATS,
DEFAULT_ORGANIZE_BY,
NbAutoexportConfig,
SAVE_PROGRESS_INDICATOR_FILE,
)


def test_configure_defaults(tmp_path):
result = CliRunner().invoke(app, ["configure", str(tmp_path)])
assert result.exit_code == 0

config = NbAutoexportConfig.parse_file(
path=tmp_path / SAVE_PROGRESS_INDICATOR_FILE, content_type="application/json"
)

expected_config = NbAutoexportConfig()
assert config == expected_config


def test_configure_specified(tmp_path):
export_formats = ["script", "html"]
organize_by = "extension"
clean = True
assert export_formats != DEFAULT_EXPORT_FORMATS
assert organize_by != DEFAULT_ORGANIZE_BY
assert clean != DEFAULT_CLEAN

cmd_list = ["configure", str(tmp_path)]
for fmt in export_formats:
cmd_list.extend(["-f", fmt])
cmd_list.extend(["-b", organize_by])
if clean:
cmd_list.append("--clean")

result = CliRunner().invoke(app, cmd_list)
assert result.exit_code == 0

config = NbAutoexportConfig.parse_file(
path=tmp_path / SAVE_PROGRESS_INDICATOR_FILE, content_type="application/json"
)

expected_config = NbAutoexportConfig(
export_formats=export_formats, organize_by=organize_by, clean=clean
)
assert config == expected_config


def test_invalid_export_format():
runner = CliRunner()
result = runner.invoke(app, ["configure", "-f", "invalid-output-format"])
assert result.exit_code == 2
assert (
"Error: Invalid value for '--export-format' / '-f': invalid choice: invalid-output-format"
in result.output
)


def test_invalid_organize_by():
runner = CliRunner()
result = runner.invoke(app, ["configure", "-b", "invalid-organize-by"])
assert result.exit_code == 2
assert (
"Invalid value for '--organize-by' / '-b': invalid choice: invalid-organize-by"
in result.output
)


def test_refuse_overwrite(tmp_path):
(tmp_path / ".nbautoexport").touch()
runner = CliRunner()
result = runner.invoke(app, ["configure", str(tmp_path)])
assert result.exit_code == 1
assert "Detected existing autoexport configuration at" in result.output


def test_force_overwrite(tmp_path):
(tmp_path / ".nbautoexport").touch()
runner = CliRunner()
result = runner.invoke(
app, ["configure", str(tmp_path), "-o", "-f", "script", "-f", "html", "-b", "notebook"]
)
assert result.exit_code == 0
with (tmp_path / ".nbautoexport").open("r") as fp:
config = json.load(fp)

expected_config = NbAutoexportConfig(export_formats=["script", "html"], organize_by="notebook")
assert config == expected_config


def test_install_no_jupyter_config_warning(tmp_path, monkeypatch):
def mock_jupyter_config_dir():
return str(tmp_path)

monkeypatch.setattr(jupyter_config, "jupyter_config_dir", mock_jupyter_config_dir)

result = CliRunner().invoke(app, ["configure", str(tmp_path)])
assert result.exit_code == 0
assert "Warning: nbautoexport is not properly installed with Jupyter." in result.output
jayqi marked this conversation as resolved.
Show resolved Hide resolved


def test_install_no_initialize_warning(tmp_path, monkeypatch):
def mock_jupyter_config_dir():
return str(tmp_path)

monkeypatch.setattr(jupyter_config, "jupyter_config_dir", mock_jupyter_config_dir)

(tmp_path / "jupyter_notebook_config.py").touch()

result = CliRunner().invoke(app, ["configure", str(tmp_path)])
assert result.exit_code == 0
assert "Warning: nbautoexport is not properly installed with Jupyter." in result.output


def test_install_oudated_initialize_warning(tmp_path, monkeypatch):
def mock_jupyter_config_dir():
return str(tmp_path)

monkeypatch.setattr(nbautoexport, "jupyter_config_dir", mock_jupyter_config_dir)

jupyter_config_path = tmp_path / "jupyter_notebook_config.py"
with jupyter_config_path.open("w") as fp:
initialize_block = jupyter_config.version_regex.sub(
"0", jupyter_config.post_save_hook_initialize_block
)
fp.write(initialize_block)

result = CliRunner().invoke(app, ["configure", str(tmp_path)])
assert result.exit_code == 0
assert "Warning: nbautoexport initialize is an older version." in result.output


def test_install_no_warning(tmp_path, monkeypatch):
def mock_jupyter_config_dir():
return str(tmp_path)

monkeypatch.setattr(jupyter_config, "jupyter_config_dir", mock_jupyter_config_dir)
monkeypatch.setattr(nbautoexport, "jupyter_config_dir", mock_jupyter_config_dir)

jupyter_config.install_post_save_hook(tmp_path / "jupyter_notebook_config.py")

result = CliRunner().invoke(app, ["configure", str(tmp_path)])
assert result.exit_code == 0
assert "Warning:" not in result.output
Loading