diff --git a/mamba_gator/envmanager.py b/mamba_gator/envmanager.py index 3b010d85..1b4af97b 100644 --- a/mamba_gator/envmanager.py +++ b/mamba_gator/envmanager.py @@ -395,15 +395,19 @@ async def import_env( Returns: Dict[str, str]: Create command output """ - with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=file_name) as f: + regex = re.compile('^@EXPLICIT$', re.MULTILINE) + is_explicit_list = regex.search(file_content) + + env_argument = [] if is_explicit_list else ["env"] + + with tempfile.NamedTemporaryFile(mode="w", suffix=file_name) as f: name = f.name f.write(file_content) + f.flush() - ans = await self._execute( - self.manager, "env", "create", "-q", "--json", "-n", env, "--file", name - ) - # Remove temporary file - os.unlink(name) + ans = await self._execute( + self.manager, *env_argument, "create", "-q", "--json", "-n", env, "--file", name + ) rcode, output = ans if rcode > 0: @@ -995,3 +999,13 @@ async def remove_packages(self, env: str, packages: List[str]) -> Dict[str, str] ) _, output = ans return self._clean_conda_json(output) + + async def get_subdir(self): + rcode, output = await self._execute( + self.manager, "list", "--explicit" + ) + if rcode == 0: + regex = re.compile('^# platform: ([a-z\-0-9]+)$', re.MULTILINE) + return {'subdir': regex.findall(output)[0]} + + raise RuntimeError('subdir not found') diff --git a/mamba_gator/handlers.py b/mamba_gator/handlers.py index b722e759..87d7570c 100644 --- a/mamba_gator/handlers.py +++ b/mamba_gator/handlers.py @@ -475,6 +475,15 @@ def delete(self, index: int): self.finish() +class SubdirHandler(EnvBaseHandler): + @tornado.web.authenticated + async def get(self): + """`GET /subdir` Get the conda-subdir. + """ + idx = self._stack.put(self.env_manager.get_subdir) + + self.redirect_to_task(idx) + # ----------------------------------------------------------------------------- # URL to handler mappings # ----------------------------------------------------------------------------- @@ -488,6 +497,7 @@ def delete(self, index: int): (r"/channels", ChannelsHandler), (r"/environments", EnvironmentsHandler), # GET / POST (r"/environments/%s" % _env_regex, EnvironmentHandler), # GET / PATCH / DELETE + (r"/subdir", SubdirHandler), # GET # PATCH / POST / DELETE (r"/environments/%s/packages" % _env_regex, PackagesEnvironmentHandler), (r"/packages", PackagesHandler), # GET diff --git a/mamba_gator/navigator/main.py b/mamba_gator/navigator/main.py index 53cca0c5..479eb64d 100644 --- a/mamba_gator/navigator/main.py +++ b/mamba_gator/navigator/main.py @@ -9,7 +9,9 @@ ExtensionHandlerMixin, ) from jupyter_server.utils import url_path_join as ujoin +from jupyter_core.application import base_aliases from jupyterlab_server import LabServerApp +from traitlets import Unicode, Dict, Bool from mamba_gator._version import __version__ from mamba_gator.handlers import _load_jupyter_server_extension from mamba_gator.log import get_logger @@ -20,6 +22,12 @@ class MambaNavigatorHandler( ExtensionHandlerJinjaMixin, ExtensionHandlerMixin, JupyterHandler ): + extra_settings = None + + def initialize(self, extra_settings, **kwargs): + self.extra_settings = extra_settings + super().initialize(**kwargs) + def get(self): config_data = { "appVersion": __version__, @@ -27,6 +35,7 @@ def get(self): "token": self.settings["token"], "fullStaticUrl": ujoin(self.base_url, "static", self.name), "frontendUrl": ujoin(self.base_url, "gator/"), + **self.extra_settings } return self.write( self.render_template( @@ -56,8 +65,65 @@ class MambaNavigator(LabServerApp): user_settings_dir = os.path.join(HERE, "user_settings") workspaces_dir = os.path.join(HERE, "workspaces") + quetz_url = Unicode( + '', + config=True, + help="The Quetz server to use for creating new environments" + ) + + quetz_solver_url = Unicode( + '', + config=True, + help="The Quetz server to use for solving, if this is a different server than 'quetzUrl'", + ) + + companions = Dict( + {}, + config=True, + help="{'package name': 'semver specification'} - pre and post releases not supported", + ) + + from_history = Bool( + False, + config=True, + help="Use --from-history or not for `conda env export`", + ) + + types = Dict( + { + "Python 3": ["python=3", "ipykernel"], + "R": ["r-base", "r-essentials"] + }, + config=True, + help="Type of environment available when creating it from scratch.", + ) + + white_list = Bool( + False, + config=True, + help="Show only environment corresponding to whitelisted kernels", + ) + + aliases = dict(base_aliases) + aliases.update({ + 'quetz_url': 'MambaNavigator.quetz_url', + 'quetz_solver_url': 'MambaNavigator.quetz_solver_url', + 'companions': 'MambaNavigator.companions', + 'from_history': 'MambaNavigator.from_history', + 'types': 'MambaNavigator.types', + 'white_list': 'MambaNavigator.white_list' + }) + def initialize_handlers(self): - self.handlers.append(("/gator", MambaNavigatorHandler)) + self.handlers.append(("/gator", MambaNavigatorHandler, dict( + extra_settings=dict( + quetzUrl=self.quetz_url, + quetzSolverUrl=self.quetz_solver_url, + companions=self.companions, + fromHistory=self.from_history, + types=self.types, + whiteList=self.white_list + )))) super().initialize_handlers() def start(self): diff --git a/packages/common/package.json b/packages/common/package.json index 131588df..7bc2ba4d 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -48,6 +48,8 @@ "@lumino/coreutils": "^1.5.3", "@lumino/signaling": "^1.4.3", "@lumino/widgets": "^1.16.1", + "codemirror": "^5.60.0", + "codemirror-show-hint": "^5.58.3", "jupyterlab_toastify": "^4.1.3", "d3": "^5.5.0", "react-d3-graph": "^2.5.0", @@ -60,6 +62,7 @@ "@babel/core": "^7.0.0", "@babel/preset-env": "^7.0.0", "@jupyterlab/testutils": "^3.0.0", + "@types/codemirror": "^0.0.108", "@types/jest": "^26.0.0", "@types/react": "^17.0.0", "@types/react-d3-graph": "^2.3.4", diff --git a/packages/common/src/components/CondaEnvList.tsx b/packages/common/src/components/CondaEnvList.tsx index 4c651f4e..b84edfdc 100644 --- a/packages/common/src/components/CondaEnvList.tsx +++ b/packages/common/src/components/CondaEnvList.tsx @@ -55,6 +55,10 @@ export interface IEnvListProps { * Environment remove handler */ onRemove(): void; + /** + * Environment solve handler + */ + onSolve?: (() => void) | false; } /** @@ -92,6 +96,7 @@ export const CondaEnvList: React.FunctionComponent = ( onExport={props.onExport} onRefresh={props.onRefresh} onRemove={props.onRemove} + onSolve={props.onSolve} />
{ + if (editor) { + if (props.content !== undefined && props.content !== editor.getValue()) { + editor.setValue(props.content); + editor.refresh(); + } + return; + } + const newEditor = CodeMirror(codemirrorElem.current, { + value: props.content || '', + lineNumbers: true, + extraKeys: { + 'Ctrl-Space': 'autocomplete', + 'Ctrl-Tab': 'autocomplete' + }, + tabSize: 2, + mode: 'yaml', + autofocus: true + }); + if (props.onContentChange) { + newEditor.on('change', (instance: Editor) => + props.onContentChange(instance.getValue()) + ); + } + setEditor(newEditor); + + /* Apply lab styles to this codemirror instance */ + codemirrorElem.current.childNodes[0].classList.add('cm-s-jupyter'); + }); + return ( +
editor && editor.refresh()} + /> + ); +} + +/** + * Solve the environment provided in environment_yml on the Quetz server and create it on the + * backend. + * + * @param environment_yml - The environment.yml content + * @param environmentManager - The Conda environment manager + * @param expandChannelUrl - The environment_yml contains channel names that should be expanded to + * channel URLS + * @param onMessage - Callback to provide feedback about the process + */ +export async function solveAndCreateEnvironment( + environment_yml: string, + environmentManager: IEnvironmentManager, + expandChannelUrl: boolean, + onMessage?: (msg: string) => void +): Promise { + const name = condaHint.getName(environment_yml); + const { quetzUrl, quetzSolverUrl, subdir } = environmentManager; + + let message = 'Solving environment...'; + onMessage && onMessage(message); + let toastId = await INotification.inProgress(message); + try { + const explicitList = await condaHint.fetchSolve( + quetzUrl, + quetzSolverUrl, + (await subdir()).subdir, + environment_yml, + expandChannelUrl + ); + await INotification.update({ + toastId, + message: 'Environment has been solved.', + type: 'success', + autoClose: 5000 + }); + + message = `creating environment ${name}...`; + onMessage && onMessage(message); + toastId = await INotification.inProgress(message); + await environmentManager.import(name, explicitList); + + message = `Environment ${name} created.`; + onMessage && onMessage(message); + await INotification.update({ + toastId, + message, + type: 'success', + autoClose: 5000 + }); + } catch (error) { + onMessage && onMessage(error.message); + if (toastId) { + await INotification.update({ + toastId, + message: error.message, + type: 'error', + autoClose: 0 + }); + } + } +} + +export interface ICondaEnvSolveDialogProps { + /** + * The Conda subdir (or platform e.g. osx-64, linux-32) of the backend + */ + subdir: string; + /** + * The Conda environment manager + */ + environmentManager: IEnvironmentManager; +} + +export function CondaEnvSolveDialog( + props: ICondaEnvSolveDialogProps +): JSX.Element { + const [environment_yml, setEnvironment_yml] = React.useState(''); + const [solveState, setSolveState] = React.useState(null); + + return ( +
+
+ +
+
+ + {solveState} +
+
+ ); +} diff --git a/packages/common/src/components/CondaEnvToolBar.tsx b/packages/common/src/components/CondaEnvToolBar.tsx index 061e3100..3ff5a1dd 100644 --- a/packages/common/src/components/CondaEnvToolBar.tsx +++ b/packages/common/src/components/CondaEnvToolBar.tsx @@ -4,6 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { ToolbarButtonComponent } from '@jupyterlab/apputils'; import { addIcon, + buildIcon, Button, closeIcon, downloadIcon, @@ -52,6 +53,10 @@ export interface ICondaEnvToolBarProps { * Remove environment handler */ onRemove(): void; + /** + * Solve environment handler + */ + onSolve?: (() => void) | false; } export const CondaEnvToolBar = (props: ICondaEnvToolBarProps): JSX.Element => { @@ -80,6 +85,13 @@ export const CondaEnvToolBar = (props: ICondaEnvToolBarProps): JSX.Element => { tooltip="Create" onClick={props.onCreate} /> + {props.onSolve && ( + + )}