diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6b86e47..ff56b6b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -43,17 +43,17 @@ jobs: sudo apt-get update sudo apt-get install zsh tcsh cat /etc/shells - - uses: conda-incubator/setup-miniconda@master + + - name: Install Conda environment from environment.yml + uses: mamba-org/setup-micromamba@v2 + id: conda with: - channels: conda-forge - channel-priority: strict - activate-environment: jupyter-forward-dev - auto-update-conda: false - python-version: ${{ matrix.python-version }} - mamba-version: "*" - use-mamba: true - miniforge-variant: Mambaforge + # environment-file is not assumed anymore environment-file: ci/environment.yml + create-args: >- + python=${{ matrix.python-version }} + # now called cache-environment + cache-environment: true - name: Install jupyter-forward run: | diff --git a/.github/workflows/upstream-dev-ci.yaml b/.github/workflows/upstream-dev-ci.yaml index 47f366d..aacb77c 100644 --- a/.github/workflows/upstream-dev-ci.yaml +++ b/.github/workflows/upstream-dev-ci.yaml @@ -40,18 +40,17 @@ jobs: sudo apt-get update sudo apt-get install zsh tcsh cat /etc/shells - - uses: conda-incubator/setup-miniconda@v3 + + - name: Install Conda environment from environment.yml + uses: mamba-org/setup-micromamba@v2 id: conda with: - channels: conda-forge,nodefaults - channel-priority: strict - activate-environment: jupyter-forward-dev - auto-update-conda: false - python-version: ${{ matrix.python-version }} + # environment-file is not assumed anymore environment-file: ci/environment-upstream-dev.yml - mamba-version: "*" - use-mamba: true - miniforge-variant: Mambaforge + create-args: >- + python=${{ matrix.python-version }} + # now called cache-environment + cache-environment: true - name: Install jupyter-forward id: install diff --git a/jupyter_forward/core.py b/jupyter_forward/core.py index f325e42..51b9f8b 100644 --- a/jupyter_forward/core.py +++ b/jupyter_forward/core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import dataclasses import datetime import getpass @@ -79,15 +80,12 @@ def _authenticate(self): f'[bold cyan]Authenticating user ({self.session.user}) from client ({socket.gethostname()}) to remote host ({self.session.host})' ) # Try passwordless authentication - try: - self.session.open() - except ( + with contextlib.suppress( paramiko.ssh_exception.BadAuthenticationType, paramiko.ssh_exception.AuthenticationException, paramiko.ssh_exception.SSHException, ): - pass - + self.session.open() # Prompt for password and token (2FA) if not self.session.is_connected: for _ in range(2): @@ -110,10 +108,10 @@ def _authenticate(self): def _check_shell(self): console.rule('[bold green]Verifying shell location', characters='*') if self.shell is None: - shell = self.session.run('echo $SHELL || echo $0', hide='out').stdout.strip() - if not shell: + if shell := self.session.run('echo $SHELL || echo $0', hide='out').stdout.strip(): + self.shell = shell + else: raise ValueError('Could not determine shell. Please specify one using --shell.') - self.shell = shell else: # Get the full path to the shell in case the user specified a shell name self.shell = self.run_command(f'which {self.shell}').stdout.strip() @@ -225,23 +223,36 @@ def _generate_redirect_command(self, *, log_file: str, command: str) -> str: def _conda_activate_cmd(self): console.rule( - '[bold green]Running jupyter sanity checks', + '[bold green]Running Jupyter sanity checks', characters='*', ) check_jupyter_status = 'which jupyter' - conda_activate_cmd = 'source activate' + activate_cmds = ['source activate', 'conda activate'] + + # Check for mamba availability and prioritize it if found + try: + mamba_check = self.run_command('which mamba', warn=False, echo=False, exit=False) + if not mamba_check.failed: + activate_cmds = ['mamba activate'] + except Exception as e: + console.print(f'[bold yellow]:warning: Mamba check failed: {e}') + + # Attempt activation if self.conda_env: - try: - self.run_command(f'{conda_activate_cmd} {self.conda_env} && {check_jupyter_status}') - except SystemExit: - console.print( - f'[bold red]:x: `{conda_activate_cmd}` failed. Trying `conda activate`...' - ) - self.run_command(f'conda activate {self.conda_env} && {check_jupyter_status}') - conda_activate_cmd = 'conda activate' + for cmd in activate_cmds: + try: + self.run_command(f'{cmd} {self.conda_env} && {check_jupyter_status}') + return cmd # Return the successfully executed command + except SystemExit: + console.print(f'[bold red]:x: `{cmd}` failed. Trying next...') else: self.run_command(check_jupyter_status) - return conda_activate_cmd + + # Final fallback if all commands fail + console.print( + '[bold red]:x: Could not activate environment. Ensure Conda or Mamba is installed.' + ) + sys.exit(1) def _parse_log_file(self): # wait for logfile to contain access info, then write it to screen @@ -253,13 +264,11 @@ def _parse_log_file(self): ): # TODO: Ensure this loop doesn't run forever if the log file is not found or empty while condition: - try: + with contextlib.suppress(invoke.exceptions.UnexpectedExit): result = self.run_command(f'cat {self.log_file}', echo=False, hide='out') if 'is running at:' in result.stdout.strip(): condition = False stdout = result.stdout - except invoke.exceptions.UnexpectedExit: - pass return parse_stdout(stdout) def _prepare_batch_job_script(self, command): diff --git a/jupyter_forward/helpers.py b/jupyter_forward/helpers.py index 79cf2c6..b18eb83 100644 --- a/jupyter_forward/helpers.py +++ b/jupyter_forward/helpers.py @@ -3,6 +3,7 @@ import getpass import re import socket +import typing import urllib.parse from .console import console @@ -55,7 +56,7 @@ def is_port_available(port) -> bool: return status != 0 -def parse_stdout(stdout: str) -> dict[str, str]: +def parse_stdout(stdout: str) -> dict[str, typing.Any | None]: """Parses stdout to determine remote_hostname, port, token, url Parameters diff --git a/pyproject.toml b/pyproject.toml index 763f1c8..69764b0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,132 +1,124 @@ [build-system] -requires = ["setuptools>=64", "setuptools-scm[toml]>=6.2", "wheel"] -build-backend = "setuptools.build_meta" - + build-backend = "setuptools.build_meta" + requires = ["setuptools-scm[toml]>=6.2", "setuptools>=64", "wheel"] [project] -name = "jupyter-forward" -description = "Jupyter Lab Port Forwarding Utility" -readme = "README.md" -license = {text="Apache Software License 2.0"} -requires-python = ">=3.10" -maintainers = [{ name = "Xdev", email = "xdev@ucar.edu" }] -keywords = ["jupyter-forward"] -classifiers = [ - "Development Status :: 2 - Pre-Alpha", - "Intended Audience :: Science/Research", - "License :: OSI Approved :: Apache Software License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Topic :: Scientific/Engineering", -] - - - - -dynamic = ["version", "dependencies"] + classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: Apache Software License", + "Natural Language :: English", + "Operating System :: OS Independent", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Topic :: Scientific/Engineering", + ] + description = "Jupyter Lab Port Forwarding Utility" + keywords = ["jupyter-forward"] + license = { text = "Apache Software License 2.0" } + maintainers = [{ name = "Xdev", email = "xdev@ucar.edu" }] + name = "jupyter-forward" + readme = "README.md" + requires-python = ">=3.10" + + dynamic = ["dependencies", "version"] [tool.setuptools.dynamic] -dependencies = { file = ["requirements.txt"] } -optional-dependencies = { dev = { file = ["requirements-dev.txt"] } } + dependencies = { file = ["requirements.txt"] } + optional-dependencies = { dev = { file = ["requirements-dev.txt"] } } [project.scripts] -jlab-forward = "jupyter_forward.cli:main" -jupyter-forward = "jupyter_forward.cli:main" + jlab-forward = "jupyter_forward.cli:main" + jupyter-forward = "jupyter_forward.cli:main" [project.urls] -Documentation = "https://github.com/ncar-xdev/jupyter-forward" -Homepage = "https://github.com/ncar-xdev/jupyter-forward" -Source = "https://github.com/ncar-xdev/jupyter-forward" -Tracker = "https://github.com/ncar-xdev/jupyter-forward/issues" + Documentation = "https://github.com/ncar-xdev/jupyter-forward" + Homepage = "https://github.com/ncar-xdev/jupyter-forward" + Source = "https://github.com/ncar-xdev/jupyter-forward" + Tracker = "https://github.com/ncar-xdev/jupyter-forward/issues" [tool.setuptools.packages.find] -include = ["jupyter_forward*"] + include = ["jupyter_forward*"] [tool.setuptools.package-data] -offsets_db_data = ["py.typed"] - - + offsets_db_data = ["py.typed"] [tool.setuptools_scm] -version_scheme = "post-release" -local_scheme = "node-and-date" -fallback_version = "999" -write_to = "jupyter_forward/_version.py" -write_to_template = '__version__ = "{version}"' - + fallback_version = "999" + local_scheme = "node-and-date" + version_scheme = "post-release" + write_to = "jupyter_forward/_version.py" + write_to_template = '__version__ = "{version}"' [tool.ruff] -line-length = 100 -target-version = "py310" - -builtins = ["ellipsis"] -# Exclude a variety of commonly ignored directories. -exclude = [ - ".bzr", - ".direnv", - ".eggs", - ".git", - ".git-rewrite", - ".hg", - ".ipynb_checkpoints", - ".mypy_cache", - ".nox", - ".pants.d", - ".pyenv", - ".pytest_cache", - ".pytype", - ".ruff_cache", - ".svn", - ".tox", - ".venv", - ".vscode", - "__pypackages__", - "_build", - "buck-out", - "build", - "dist", - "node_modules", - "site-packages", - "venv", -] + line-length = 100 + target-version = "py310" + + builtins = ["ellipsis"] + # Exclude a variety of commonly ignored directories. + exclude = [ + ".bzr", + ".direnv", + ".eggs", + ".git", + ".git-rewrite", + ".hg", + ".ipynb_checkpoints", + ".mypy_cache", + ".nox", + ".pants.d", + ".pyenv", + ".pytest_cache", + ".pytype", + ".ruff_cache", + ".svn", + ".tox", + ".venv", + ".vscode", + "__pypackages__", + "_build", + "buck-out", + "build", + "dist", + "node_modules", + "site-packages", + "venv", + ] [tool.ruff.lint] -per-file-ignores = {} -ignore = [ - "E721", # Comparing types instead of isinstance - "E741", # Ambiguous variable names - "E501", # Conflicts with ruff format -] -select = [ - # Pyflakes - "F", - # Pycodestyle - "E", - "W", - # isort - "I", - # Pyupgrade - "UP", -] + ignore = [ + "E501", # Conflicts with ruff format + "E721", # Comparing types instead of isinstance + "E741", # Ambiguous variable names + ] + per-file-ignores = {} + select = [ + # Pyflakes + "F", + # Pycodestyle + "E", + "W", + # isort + "I", + # Pyupgrade + "UP", + ] [tool.ruff.lint.mccabe] -max-complexity = 18 + max-complexity = 18 [tool.ruff.lint.isort] -known-first-party = ["jupyter_forward"] + known-first-party = ["jupyter_forward"] [tool.ruff.format] -quote-style = "single" -docstring-code-format = true + docstring-code-format = true + quote-style = "single" [tool.ruff.lint.pydocstyle] -convention = "numpy" - + convention = "numpy" [tool.pytest.ini_options] -console_output_style = "count" -addopts = "--cov=./ --cov-report=xml --verbose" + addopts = "--cov=./ --cov-report=xml --verbose" + console_output_style = "count" diff --git a/readthedocs.yml b/readthedocs.yml index 1aed175..9baa166 100644 --- a/readthedocs.yml +++ b/readthedocs.yml @@ -2,6 +2,10 @@ version: 2 conda: environment: ci/environment-docs.yml build: - os: "ubuntu-20.04" + os: "ubuntu-24.04" tools: - python: "mambaforge-4.10" + python: "mambaforge-latest" + +sphinx: + configuration: docs/source/conf.py + fail_on_warning: false diff --git a/tests/test_core.py b/tests/test_core.py index cbe8a18..90adaf2 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -36,7 +36,7 @@ def dummy_fallback_auth_handler(): @pytest.fixture(scope='package') def runner(request): remote = jupyter_forward.RemoteRunner( - f"{os.environ['JUPYTER_FORWARD_SSH_TEST_USER']}@{os.environ['JUPYTER_FORWARD_SSH_TEST_HOSTNAME']}", + f'{os.environ["JUPYTER_FORWARD_SSH_TEST_USER"]}@{os.environ["JUPYTER_FORWARD_SSH_TEST_HOSTNAME"]}', shell=request.param, auth_handler=dummy_auth_handler, fallback_auth_handler=dummy_fallback_auth_handler, @@ -59,7 +59,7 @@ def runner(request): ) def test_runner_init(port, conda_env, notebook, notebook_dir, port_forwarding, identity, shell): remote_runner = jupyter_forward.RemoteRunner( - f"{os.environ['JUPYTER_FORWARD_SSH_TEST_USER']}@{os.environ['JUPYTER_FORWARD_SSH_TEST_HOSTNAME']}", + f'{os.environ["JUPYTER_FORWARD_SSH_TEST_USER"]}@{os.environ["JUPYTER_FORWARD_SSH_TEST_HOSTNAME"]}', port=port, conda_env=conda_env, notebook=notebook, @@ -79,7 +79,7 @@ def test_runner_init(port, conda_env, notebook, notebook_dir, port_forwarding, i def test_runner_init_notebook_dir_error(): with pytest.raises(ValueError): jupyter_forward.RemoteRunner( - f"{os.environ['JUPYTER_FORWARD_SSH_TEST_USER']}@{os.environ['JUPYTER_FORWARD_SSH_TEST_HOSTNAME']}", + f'{os.environ["JUPYTER_FORWARD_SSH_TEST_USER"]}@{os.environ["JUPYTER_FORWARD_SSH_TEST_HOSTNAME"]}', notebook_dir='~/notebooks/', notebook='~/my_notebook.ipynb', ) @@ -89,7 +89,7 @@ def test_runner_init_notebook_dir_error(): def test_runner_init_port_unavailable(): with pytest.raises(SystemExit): jupyter_forward.RemoteRunner( - f"{os.environ['JUPYTER_FORWARD_SSH_TEST_USER']}@{os.environ['JUPYTER_FORWARD_SSH_TEST_HOSTNAME']}", + f'{os.environ["JUPYTER_FORWARD_SSH_TEST_USER"]}@{os.environ["JUPYTER_FORWARD_SSH_TEST_HOSTNAME"]}', port=22, ) @@ -98,7 +98,7 @@ def test_runner_init_port_unavailable(): def test_runner_authentication_error(): with pytest.raises(SystemExit): jupyter_forward.RemoteRunner( - f"foobar@{os.environ['JUPYTER_FORWARD_SSH_TEST_HOSTNAME']}", + f'foobar@{os.environ["JUPYTER_FORWARD_SSH_TEST_HOSTNAME"]}', auth_handler=dummy_auth_handler, fallback_auth_handler=dummy_fallback_auth_handler, ) @@ -126,7 +126,7 @@ def test_run_command(runner, command, kwargs): if kwargs.get('asynchronous', False): out = out.join() assert not out.failed - f"{os.environ['HOME']}" in out.stdout.strip() + f'{os.environ["HOME"]}' in out.stdout.strip() @requires_ssh @@ -159,7 +159,7 @@ def test_set_logs(runner): assert '/.jupyter_forward' in runner.log_dir runner._set_log_file() now = datetime.datetime.now() - assert f"log_{now.strftime('%Y-%m-%dT%H')}" in runner.log_file + assert f'log_{now.strftime("%Y-%m-%dT%H")}' in runner.log_file @requires_ssh @@ -192,9 +192,10 @@ def test_parse_log_file(runner): @requires_ssh @pytest.mark.parametrize('runner', SHELLS, indirect=True) @pytest.mark.parametrize('environment', ['jupyter-forward-dev', None]) +@pytest.mark.xfail( + ON_GITHUB_ACTIONS, reason='Fails on GitHub Actions due to inconsistent shell behavior' +) def test_conda_activate_cmd(runner, environment): - if ON_GITHUB_ACTIONS and ('csh' in runner.shell or 'zsh' in runner.shell): - pytest.xfail('Fails on GitHub Actions due to inconsistent shell behavior') runner.conda_env = environment cmd = runner._conda_activate_cmd() assert cmd in ['source activate', 'conda activate']