Skip to content

Commit

Permalink
Merge pull request #1743 from emlys/feature/1651
Browse files Browse the repository at this point in the history
Support installing plugins from specific ref; and from local path
  • Loading branch information
dcdenu4 authored Jan 30, 2025
2 parents 2b38221 + 823ea7f commit 764477b
Show file tree
Hide file tree
Showing 5 changed files with 144 additions and 45 deletions.
80 changes: 54 additions & 26 deletions workbench/src/main/setupAddRemovePlugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,37 +52,65 @@ function spawnWithLogging(cmd, args, options) {
export function setupAddPlugin() {
ipcMain.handle(
ipcMainChannels.ADD_PLUGIN,
async (e, pluginURL) => {
async (e, url, revision, path) => {
try {
logger.info('adding plugin at', pluginURL);
let pyprojectTOML;
let installString;
const micromamba = settingsStore.get('micromamba');
const rootPrefix = upath.join(process.resourcesPath, 'micromamba_envs');
const baseEnvPrefix = upath.join(rootPrefix, 'invest_base');
// Create invest_base environment, if it doesn't already exist
// The purpose of this environment is just to ensure that git is available
if (!fs.existsSync(baseEnvPrefix)) {
if (url) { // install from git URL
if (revision) {
installString = `git+${url}@${revision}`;
logger.info(`adding plugin from ${installString}`);
} else {
installString = `git+${url}`;
logger.info(`adding plugin from ${installString} at default branch`);
}

const baseEnvPrefix = upath.join(rootPrefix, 'invest_base');
// Create invest_base environment, if it doesn't already exist
// The purpose of this environment is just to ensure that git is available
if (!fs.existsSync(baseEnvPrefix)) {
await spawnWithLogging(
micromamba,
['create', '--yes', '--prefix', `"${baseEnvPrefix}"`, '-c', 'conda-forge', 'git']
);
}

// Create a temporary directory and check out the plugin's pyproject.toml,
// without downloading any extra files or git history
const tmpPluginDir = fs.mkdtempSync(upath.join(tmpdir(), 'natcap-invest-'));
await spawnWithLogging(
micromamba,
['run', '--prefix', `"${baseEnvPrefix}"`,
'git', 'clone', '--depth', 1, '--no-checkout', url, tmpPluginDir]);
let head = 'HEAD';
if (revision) {
head = 'FETCH_HEAD';
await spawnWithLogging(
micromamba,
['run', '--prefix', `"${baseEnvPrefix}"`, 'git', 'fetch', 'origin', `${revision}`],
{ cwd: tmpPluginDir }
);
}
await spawnWithLogging(
micromamba,
['create', '--yes', '--prefix', `"${baseEnvPrefix}"`, '-c', 'conda-forge', 'git']
['run', '--prefix', `"${baseEnvPrefix}"`, 'git', 'checkout', head, '--', 'pyproject.toml'],
{ cwd: tmpPluginDir }
);
// Read in the plugin's pyproject.toml, then delete it
pyprojectTOML = toml.parse(fs.readFileSync(
upath.join(tmpPluginDir, 'pyproject.toml')
).toString());
fs.rmSync(tmpPluginDir, { recursive: true, force: true });
} else { // install from local path
logger.info(`adding plugin from ${path}`);
installString = path;
// Read in the plugin's pyproject.toml
pyprojectTOML = toml.parse(fs.readFileSync(
upath.join(path, 'pyproject.toml')
).toString());
}
// Create a temporary directory and check out the plugin's pyproject.toml
const tmpPluginDir = fs.mkdtempSync(upath.join(tmpdir(), 'natcap-invest-'));
await spawnWithLogging(
micromamba,
['run', '--prefix', `"${baseEnvPrefix}"`,
'git', 'clone', '--depth', '1', '--no-checkout', pluginURL, tmpPluginDir]
);
await spawnWithLogging(
micromamba,
['run', '--prefix', `"${baseEnvPrefix}"`, 'git', 'checkout', 'HEAD', 'pyproject.toml'],
{ cwd: tmpPluginDir }
);
// Read in the plugin's pyproject.toml, then delete it
const pyprojectTOML = toml.parse(fs.readFileSync(
upath.join(tmpPluginDir, 'pyproject.toml')
).toString());
fs.rmSync(tmpPluginDir, { recursive: true, force: true });

// Access plugin metadata from the pyproject.toml
const pluginID = pyprojectTOML.tool.natcap.invest.model_id;
Expand All @@ -103,7 +131,7 @@ export function setupAddPlugin() {
logger.info('created micromamba env for plugin');
await spawnWithLogging(
micromamba,
['run', '--prefix', `"${pluginEnvPrefix}"`, 'pip', 'install', `git+${pluginURL}`]
['run', '--prefix', `"${pluginEnvPrefix}"`, 'pip', 'install', installString]
);
logger.info('installed plugin into its env');
// Write plugin metadata to the workbench's config.json
Expand All @@ -114,7 +142,7 @@ export function setupAddPlugin() {
model_name: pluginName,
pyname: pluginPyName,
type: 'plugin',
source: pluginURL,
source: installString,
env: pluginEnvPrefix,
}
);
Expand Down
1 change: 1 addition & 0 deletions workbench/src/preload/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default {
ELECTRON_LOG_PATH: electronLogPath,
USERGUIDE_PATH: userguidePath,
LANGUAGE: ipcRenderer.sendSync(ipcMainChannels.GET_LANGUAGE),
OS: process.platform,
logger: {
debug: (message) => ipcRenderer.send(ipcMainChannels.LOGGER, 'debug', message),
info: (message) => ipcRenderer.send(ipcMainChannels.LOGGER, 'info', message),
Expand Down
99 changes: 82 additions & 17 deletions workbench/src/renderer/components/PluginModal/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useState } from 'react';
import PropTypes from 'prop-types';

import Button from 'react-bootstrap/Button';
import Col from 'react-bootstrap/Col';
import Form from 'react-bootstrap/Form';
import Modal from 'react-bootstrap/Modal';
import Spinner from 'react-bootstrap/Spinner';
Expand All @@ -15,20 +16,30 @@ export default function PluginModal(props) {
const { updateInvestList } = props;
const [showPluginModal, setShowPluginModal] = useState(false);
const [url, setURL] = useState(undefined);
const [revision, setRevision] = useState(undefined);
const [path, setPath] = useState(undefined);
const [err, setErr] = useState(undefined);
const [pluginToRemove, setPluginToRemove] = useState(undefined);
const [loading, setLoading] = useState(false);
const [plugins, setPlugins] = useState({});
const [installFrom, setInstallFrom] = useState('url');

const handleModalClose = () => {
setURL(undefined);
setRevision(undefined);
setErr(false);
setShowPluginModal(false);
};
const handleModalOpen = () => setShowPluginModal(true);

const addPlugin = () => {
setLoading(true);
ipcRenderer.invoke(ipcMainChannels.ADD_PLUGIN, url).then((addPluginErr) => {
ipcRenderer.invoke(
ipcMainChannels.ADD_PLUGIN,
installFrom === 'url' ? url : undefined, // url
installFrom === 'url' ? revision : undefined, // revision
installFrom === 'path' ? path : undefined // path
).then((addPluginErr) => {
setLoading(false);
updateInvestList();
if (addPluginErr) {
Expand Down Expand Up @@ -61,33 +72,85 @@ export default function PluginModal(props) {

const { t } = useTranslation();

let modalBody = (
<Modal.Body>
<Form>
<Form.Group className="mb-3">
<Form.Label htmlFor="url">{t('Add a plugin')}</Form.Label>
let pluginFields;
if (installFrom === 'url') {
pluginFields = (
<Form.Row>
<Form.Group as={Col} xs={7}>
<Form.Label htmlFor="url">{t('Git URL')}</Form.Label>
<Form.Control
id="url"
type="text"
placeholder={t('Enter Git URL')}
placeholder="https://github.com/owner/repo.git"
onChange={(event) => setURL(event.currentTarget.value)}
/>
<Form.Text className="text-muted">
{t('This may take several minutes')}
<i>{t('Default branch used unless otherwise specified')}</i>
</Form.Text>
</Form.Group>
<Form.Group as={Col}>
<Form.Label htmlFor="branch">{t('Branch, tag, or commit')}</Form.Label>
<Form.Control
id="branch"
type="text"
onChange={(event) => setRevision(event.currentTarget.value)}
/>
<Form.Text className="text-muted">
<i>{t('Optional')}</i>
</Form.Text>
</Form.Group>
</Form.Row>
);
} else {
pluginFields = (
<Form.Group>
<Form.Label htmlFor="path">{t('Local absolute path')}</Form.Label>
<Form.Control
id="path"
type="text"
placeholder={window.Workbench.OS === 'darwin'
? '/Users/username/path/to/plugin/'
: 'C:\\Documents\\path\\to\\plugin\\'}
onChange={(event) => setPath(event.currentTarget.value)}
/>
</Form.Group>
);
}

let modalBody = (
<Modal.Body>
<Form>
<Form.Group>
<h5 className="mb-3">{t('Add a plugin')}</h5>
<Form.Group>
<Form.Label htmlFor="installFrom">{t('Install from')}</Form.Label>
<Form.Control
id="installFrom"
as="select"
onChange={(event) => setInstallFrom(event.target.value)}
className="w-auto"
>
<option value="url">{t('git URL')}</option>
<option value="path">{t('local path')}</option>
</Form.Control>
</Form.Group>
{pluginFields}
<Button
disabled={loading}
className="mt-2"
onClick={addPlugin}
>
{t('Add')}
</Button>
<Form.Text className="text-muted">
{t('This may take several minutes')}
</Form.Text>
</Form.Group>
<hr />
<Form.Group className="mb-3">
<Form.Label htmlFor="plugin-select">{t('Remove a plugin')}</Form.Label>
<Form.Group>
<h5 className="mb-3">{t('Remove a plugin')}</h5>
<Form.Label htmlFor="selectPluginToRemove">{t('Plugin name')}</Form.Label>
<Form.Control
id="plugin-select"
id="selectPluginToRemove"
as="select"
value={pluginToRemove}
onChange={(event) => setPluginToRemove(event.currentTarget.value)}
Expand All @@ -107,7 +170,7 @@ export default function PluginModal(props) {
</Form.Control>
<Button
disabled={loading || !Object.keys(plugins).length}
className="mt-2"
className="mt-3"
onClick={removePlugin}
>
{t('Remove')}
Expand All @@ -130,13 +193,15 @@ export default function PluginModal(props) {
{t('Manage plugins')}
</Button>

<Modal show={showPluginModal} onHide={handleModalClose}>
<Modal show={showPluginModal} onHide={handleModalClose} contentClassName="plugin-modal">
<Modal.Header>
<Modal.Title>{t('Manage plugins')}</Modal.Title>
{loading && (
<Spinner animation="border" role="status" className="m-2">
<span className="sr-only">Loading...</span>
</Spinner>
<Form.Group>
<Spinner animation="border" role="status" className="m-2">
<span className="sr-only">{t('Loading...')}</span>
</Spinner>
</Form.Group>
)}
</Modal.Header>
{modalBody}
Expand Down
5 changes: 5 additions & 0 deletions workbench/src/renderer/styles/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -637,3 +637,8 @@ input[type=text]::placeholder {
background-size: 1rem 1rem;
}
}

/* Manage Plugins modal */
.plugin-modal {
width: 34rem;
}
4 changes: 2 additions & 2 deletions workbench/tests/renderer/plugin.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ describe('Add plugin modal', () => {
const managePluginsButton = await findByText('Manage plugins');
userEvent.click(managePluginsButton);

const urlField = await findByLabelText('Add a plugin');
const urlField = await findByLabelText('Git URL');
await userEvent.type(urlField, 'fake url', { delay: 0 });
const submitButton = await findByText('Add');
userEvent.click(submitButton);
Expand Down Expand Up @@ -148,7 +148,7 @@ describe('Add plugin modal', () => {
const managePluginsButton = await findByText('Manage plugins');
userEvent.click(managePluginsButton);

const pluginDropdown = await findByLabelText('Remove a plugin');
const pluginDropdown = await findByLabelText('Plugin name');
await userEvent.selectOptions(pluginDropdown, [getByRole('option', { name: 'Foo' })]);

const submitButton = await findByText('Remove');
Expand Down

0 comments on commit 764477b

Please sign in to comment.