mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
Merge branch 'release_25.1' into dev
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@galaxyproject/galaxy-client",
|
||||
"version": "24.0.0",
|
||||
"version": "25.0.4",
|
||||
"description": "Galaxy client application build system",
|
||||
"keywords": [
|
||||
"galaxy"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import createClient from "openapi-fetch";
|
||||
|
||||
import { createRateLimiterMiddleware } from "@/api/client/rateLimiter";
|
||||
import type { GalaxyApiPaths } from "@/api/schema";
|
||||
import { getAppRoot } from "@/onload/loadConfig";
|
||||
|
||||
@@ -9,7 +10,19 @@ function getBaseUrl() {
|
||||
}
|
||||
|
||||
function apiClientFactory() {
|
||||
return createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
|
||||
const client = createClient<GalaxyApiPaths>({ baseUrl: getBaseUrl() });
|
||||
|
||||
// TODO: Adjust based on server limits (maybe this goes in Galaxy config?)
|
||||
client.use(
|
||||
createRateLimiterMiddleware({
|
||||
maxRequests: 100,
|
||||
windowMs: 3000,
|
||||
retryDelay: 1000,
|
||||
maxRetries: 3,
|
||||
}),
|
||||
);
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
export type GalaxyApiClient = ReturnType<typeof apiClientFactory>;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { MessageException } from "@/api";
|
||||
import { GalaxyApi } from "@/api/client";
|
||||
import { useServerMock } from "@/api/client/__mocks__";
|
||||
|
||||
import { DEFAULT_CONFIG } from "./rateLimiter";
|
||||
|
||||
const { server, http } = useServerMock();
|
||||
|
||||
/** Spy to count number of times a 429 response is returned */
|
||||
const mock429ResponseSpy = jest.fn();
|
||||
|
||||
describe("Rate Limiter Middleware", () => {
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
let consoleErrorSpy: jest.SpyInstance;
|
||||
|
||||
/** Helper to verify that there is a 429 response without retries */
|
||||
function ensure429AndNoRetries(response: Response, error: MessageException | undefined) {
|
||||
// Verify the request failed as expected
|
||||
expect(response.status).toBe(429);
|
||||
expect(error).toBeDefined();
|
||||
expect(error?.err_code).toBe(429);
|
||||
|
||||
// Verify no retry behavior occurred by confirming console warns/errors were not called
|
||||
expect(consoleWarnSpy).not.toHaveBeenCalled();
|
||||
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Verify the mock 429 response was called only once (no retries)
|
||||
expect(mock429ResponseSpy).toHaveBeenCalledTimes(1);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
consoleErrorSpy = jest.spyOn(console, "error").mockImplementation();
|
||||
});
|
||||
afterEach(() => {
|
||||
consoleWarnSpy.mockRestore();
|
||||
consoleErrorSpy.mockRestore();
|
||||
mock429ResponseSpy.mockReset();
|
||||
});
|
||||
|
||||
it("should retry 429 GET responses", async () => {
|
||||
// Set up a mock GET endpoint that always returns 429
|
||||
server.use(
|
||||
http.get("/api/histories/{history_id}", ({ response }) => {
|
||||
mock429ResponseSpy();
|
||||
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { error, response } = await GalaxyApi().GET("/api/histories/{history_id}", {
|
||||
params: {
|
||||
path: { history_id: "test" },
|
||||
},
|
||||
});
|
||||
|
||||
// Verify the request failed as expected
|
||||
expect(response.status).toBe(429);
|
||||
expect(error).toBeDefined();
|
||||
expect(error?.err_code).toBe(429);
|
||||
|
||||
// Verify retry behavior occurred by confirming console warns/errors
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Received 429 from server, waiting ${DEFAULT_CONFIG.retryDelay}ms before retry`),
|
||||
);
|
||||
|
||||
for (let i = 1; i <= DEFAULT_CONFIG.maxRetries; i++) {
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining(`Retry ${i} also received 429`));
|
||||
}
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`Max retries reached for request to ${response.url}`),
|
||||
);
|
||||
|
||||
// Verify the mock 429 response was called the first time and then for each retry
|
||||
expect(mock429ResponseSpy).toHaveBeenCalledTimes(DEFAULT_CONFIG.maxRetries + 1);
|
||||
});
|
||||
|
||||
it("should not retry 429 POST responses", async () => {
|
||||
// Set up a mock POST endpoint that always returns 429
|
||||
server.use(
|
||||
http.post("/api/chat", ({ response }) => {
|
||||
mock429ResponseSpy();
|
||||
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { error, response } = await GalaxyApi().POST("/api/chat", {
|
||||
params: {
|
||||
query: { job_id: "test" },
|
||||
},
|
||||
body: {
|
||||
query: "test message",
|
||||
context: "test",
|
||||
},
|
||||
});
|
||||
|
||||
ensure429AndNoRetries(response, error);
|
||||
});
|
||||
|
||||
it("should not retry 429 DELETE responses", async () => {
|
||||
// Set up a mock DELETE endpoint that always returns 429
|
||||
server.use(
|
||||
http.delete("/api/datasets/{dataset_id}", ({ response }) => {
|
||||
mock429ResponseSpy();
|
||||
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { error, response } = await GalaxyApi().DELETE("/api/datasets/{dataset_id}", {
|
||||
params: {
|
||||
path: { dataset_id: "test_id" },
|
||||
query: { purge: true },
|
||||
},
|
||||
});
|
||||
|
||||
ensure429AndNoRetries(response, error);
|
||||
});
|
||||
|
||||
it("should not retry 429 PUT responses", async () => {
|
||||
// Set up a mock PUT endpoint that always returns 429
|
||||
server.use(
|
||||
http.put("/api/datasets/{dataset_id}", ({ response }) => {
|
||||
mock429ResponseSpy();
|
||||
return response("4XX").json({ err_code: 429, err_msg: "Too Many Requests" }, { status: 429 });
|
||||
}),
|
||||
);
|
||||
|
||||
const { error, response } = await GalaxyApi().PUT("/api/datasets/{dataset_id}", {
|
||||
params: {
|
||||
path: { dataset_id: "test_id" },
|
||||
},
|
||||
body: {
|
||||
deleted: false,
|
||||
},
|
||||
});
|
||||
|
||||
ensure429AndNoRetries(response, error);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Middleware } from "openapi-fetch";
|
||||
|
||||
interface RateLimitConfig {
|
||||
/** Maximum requests per window */
|
||||
maxRequests?: number;
|
||||
/** Time the requestwindow lasts in milliseconds */
|
||||
windowMs?: number;
|
||||
/** Delay between retries on 429 */
|
||||
retryDelay?: number;
|
||||
/** Maximum retry attempts */
|
||||
maxRetries?: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: Required<RateLimitConfig> = {
|
||||
maxRequests: 100,
|
||||
windowMs: 60000,
|
||||
retryDelay: 1000,
|
||||
maxRetries: 3,
|
||||
};
|
||||
|
||||
/**
|
||||
* Rate limiting middleware to control the rate of API requests.
|
||||
*
|
||||
* Uses a timed window and only allows a maximum number of requests per that window.
|
||||
*/
|
||||
export function createRateLimiterMiddleware(config: RateLimitConfig = {}): Middleware {
|
||||
const cfg = { ...DEFAULT_CONFIG, ...config };
|
||||
|
||||
let requestCount = 0;
|
||||
let windowStart = Date.now();
|
||||
|
||||
/** Resets the request window if the time has elapsed */
|
||||
function resetWindowIfNeeded() {
|
||||
const now = Date.now();
|
||||
if (now - windowStart >= cfg.windowMs) {
|
||||
requestCount = 0;
|
||||
windowStart = now;
|
||||
}
|
||||
}
|
||||
|
||||
/** Places a request in the rate limiter queue */
|
||||
async function placeRequestInQueue(): Promise<void> {
|
||||
resetWindowIfNeeded();
|
||||
|
||||
if (requestCount < cfg.maxRequests) {
|
||||
requestCount++;
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit exceeded, wait for next window
|
||||
const waitTime = cfg.windowMs - (Date.now() - windowStart);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
||||
|
||||
// After waiting, try again (this will reset the window)
|
||||
return placeRequestInQueue();
|
||||
}
|
||||
|
||||
const middleware: Middleware = {
|
||||
async onRequest({ request }) {
|
||||
await placeRequestInQueue();
|
||||
return request;
|
||||
},
|
||||
|
||||
async onResponse({ response: res, request }) {
|
||||
// Handle 429 Too Many Requests from server
|
||||
if (res.status === 429 && request.method === "GET") {
|
||||
const retryAfter = res.headers.get("Retry-After");
|
||||
const delay = retryAfter ? parseInt(retryAfter) * 1000 : cfg.retryDelay;
|
||||
|
||||
console.warn(`Received 429 from server, waiting ${delay}ms before retry`);
|
||||
|
||||
let retries = 0;
|
||||
while (retries < cfg.maxRetries) {
|
||||
retries++;
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
|
||||
// A tricky thing here is that we will bypass the middleware chain on retry
|
||||
const retryResponse = await fetch(request);
|
||||
if (retryResponse.status !== 429) {
|
||||
return retryResponse;
|
||||
}
|
||||
console.warn(`Retry ${retries} also received 429, retrying...`);
|
||||
}
|
||||
|
||||
console.error(`Max retries reached for request to ${request.url}`);
|
||||
}
|
||||
|
||||
return res;
|
||||
},
|
||||
};
|
||||
|
||||
return middleware;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { faClock } from "@fortawesome/free-regular-svg-icons";
|
||||
import { faHdd, faSitemap } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
|
||||
import { storeToRefs } from "pinia";
|
||||
@@ -123,7 +124,9 @@ function getInvocationBadges(invocation: WorkflowInvocation) {
|
||||
:title-icon="{ icon: faSitemap }"
|
||||
:title-n-lines="2"
|
||||
title-size="text"
|
||||
:update-time="invocation.update_time"
|
||||
:update-time="invocation.create_time"
|
||||
update-time-title="Invoked"
|
||||
:update-time-icon="faClock"
|
||||
@title-click="workflowName(invocation.workflow_id)"
|
||||
@click="() => cardClicked(invocation)">
|
||||
<template v-slot:description>
|
||||
|
||||
@@ -93,7 +93,7 @@ async function mountWorkflowAnnotation(version: "run_form" | "invocation", ownsW
|
||||
propsData: {
|
||||
workflowId: ownsWorkflow ? SAMPLE_WORKFLOW.id : OTHER_USER_WORKFLOW_ID,
|
||||
historyId: TEST_HISTORY_ID,
|
||||
invocationUpdateTime: version === "invocation" ? INVOCATION_TIME : undefined,
|
||||
invocationCreateTime: version === "invocation" ? INVOCATION_TIME : undefined,
|
||||
showDetails: version === "run_form",
|
||||
},
|
||||
localVue,
|
||||
@@ -165,7 +165,7 @@ describe("WorkflowAnnotation renders", () => {
|
||||
const { wrapper } = await mountWorkflowAnnotation("invocation");
|
||||
|
||||
const timeInfo = wrapper.find(SELECTORS.TIME_INFO);
|
||||
expect(timeInfo.text()).toContain("updated");
|
||||
expect(timeInfo.text()).toContain("invoked");
|
||||
expect(timeInfo.find(SELECTORS.DATE).attributes("title")).toBe(INVOCATION_TIME);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,14 +17,14 @@ import WorkflowIndicators from "@/components/Workflow/List/WorkflowIndicators.vu
|
||||
|
||||
interface Props {
|
||||
workflowId: string;
|
||||
invocationUpdateTime?: string;
|
||||
invocationCreateTime?: string;
|
||||
historyId: string;
|
||||
showDetails?: boolean;
|
||||
hideHr?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
invocationUpdateTime: undefined,
|
||||
invocationCreateTime: undefined,
|
||||
});
|
||||
|
||||
const { workflow, owned } = useWorkflowInstance(props.workflowId);
|
||||
@@ -34,7 +34,7 @@ const description = computed(() => {
|
||||
});
|
||||
|
||||
const timeElapsed = computed(() => {
|
||||
return props.invocationUpdateTime || workflow.value?.update_time;
|
||||
return props.invocationCreateTime || workflow.value?.update_time;
|
||||
});
|
||||
|
||||
const workflowTags = computed(() => {
|
||||
@@ -49,11 +49,11 @@ const workflowTags = computed(() => {
|
||||
<i v-if="timeElapsed" data-description="workflow annotation time info">
|
||||
<FontAwesomeIcon :icon="faClock" class="mr-1" />
|
||||
<span v-localize>
|
||||
{{ props.invocationUpdateTime ? "updated" : "edited" }}
|
||||
{{ props.invocationCreateTime ? "invoked" : "edited" }}
|
||||
</span>
|
||||
<UtcDate :date="timeElapsed" mode="elapsed" data-description="workflow annotation date" />
|
||||
</i>
|
||||
<span v-if="invocationUpdateTime" class="d-flex flex-gapx-1 align-items-center">
|
||||
<span v-if="invocationCreateTime" class="d-flex flex-gapx-1 align-items-center">
|
||||
<FontAwesomeIcon :icon="faHdd" />History:
|
||||
|
||||
<span class="history-link-wrapper">
|
||||
|
||||
@@ -304,7 +304,7 @@ async function onCancel() {
|
||||
<WorkflowAnnotation
|
||||
v-if="props.isFullPage"
|
||||
:workflow-id="invocation.workflow_id"
|
||||
:invocation-update-time="invocation.update_time"
|
||||
:invocation-create-time="invocation.create_time"
|
||||
:history-id="invocation.history_id">
|
||||
<template v-slot:middle-content>
|
||||
<div class="progress-bars mx-1">
|
||||
|
||||
@@ -44,7 +44,7 @@ hyphyvision:
|
||||
version: 0.0.3
|
||||
igv:
|
||||
package: "@galaxyproject/igv"
|
||||
version: 0.0.21
|
||||
version: 0.0.23
|
||||
jupyterlite:
|
||||
package: "@galaxyproject/jupyterlite"
|
||||
version: 0.0.17
|
||||
|
||||
@@ -108,13 +108,6 @@ def get_anaconda_url(container, anaconda_channel="bioconda"):
|
||||
return f"https://anaconda.org/{anaconda_channel}/{name[0]}/{name[1]}/download/linux-64/{'-'.join(name)}.tar.bz2"
|
||||
|
||||
|
||||
def prepend_anaconda_url(url):
|
||||
"""
|
||||
Take a partial url and prepend 'https://anaconda.org'
|
||||
"""
|
||||
return f"https://anaconda.org{url}"
|
||||
|
||||
|
||||
def get_test_from_anaconda(url: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Given the URL of an anaconda tarball, return tests
|
||||
@@ -132,20 +125,26 @@ def get_test_from_anaconda(url: str) -> Optional[Dict[str, Any]]:
|
||||
return None
|
||||
|
||||
|
||||
def find_anaconda_versions(name, anaconda_channel="bioconda"):
|
||||
def find_anaconda_download_url(
|
||||
name: str, version: str, build: Optional[str] = None, anaconda_channel: str = "bioconda"
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Find a list of available anaconda versions for a given container name
|
||||
Find the anaconda download url for a given package.
|
||||
"""
|
||||
r = requests.get(
|
||||
f"https://anaconda.org/{anaconda_channel}/{name}/files",
|
||||
f"https://api.anaconda.org/package/{anaconda_channel}/{name}/files",
|
||||
timeout=MULLED_SOCKET_TIMEOUT,
|
||||
)
|
||||
r.raise_for_status()
|
||||
urls = []
|
||||
for line in r.text.splitlines():
|
||||
if "download/linux" in line:
|
||||
urls.append(line.split('"')[1])
|
||||
return urls
|
||||
package_files = r.json()
|
||||
for package_file in reversed(package_files):
|
||||
if (
|
||||
package_file["version"] == version
|
||||
and (build is None or package_file["attrs"]["build"] == build)
|
||||
and package_file["attrs"]["subdir"] in ["linux-64", "noarch"]
|
||||
):
|
||||
return f"https:{package_file['download_url']}"
|
||||
return None
|
||||
|
||||
|
||||
def open_recipe_file(file, recipes_path=None, github_repo="bioconda/bioconda-recipes"):
|
||||
@@ -211,50 +210,55 @@ def deep_test_search(
|
||||
"""
|
||||
Look in bioconda-recipes repo as well as anaconda for the tests, checking in multiple possible locations. If no test is found for the specified version, search if other package versions have a test available.
|
||||
"""
|
||||
name = split_container_name(container)
|
||||
name_tuple = split_container_name(container)
|
||||
assert len(name_tuple) in (2, 3)
|
||||
name = name_tuple[0]
|
||||
version = name_tuple[1]
|
||||
build = name_tuple[2] if len(name_tuple) == 3 else None
|
||||
for f in [
|
||||
(
|
||||
get_commands_from_yaml,
|
||||
open_recipe_file,
|
||||
(f"recipes/{name[0]}/{name[1]}/meta.yaml", recipes_path, github_repo),
|
||||
(f"recipes/{name}/{version}/meta.yaml", recipes_path, github_repo),
|
||||
container,
|
||||
),
|
||||
(
|
||||
get_run_test,
|
||||
open_recipe_file,
|
||||
(f"recipes/{name[0]}/{name[1]}/run_test.sh", recipes_path, github_repo),
|
||||
(f"recipes/{name}/{version}/run_test.sh", recipes_path, github_repo),
|
||||
container,
|
||||
),
|
||||
(
|
||||
get_commands_from_yaml,
|
||||
open_recipe_file,
|
||||
(f"recipes/{name[0]}/meta.yaml", recipes_path, github_repo),
|
||||
(f"recipes/{name}/meta.yaml", recipes_path, github_repo),
|
||||
container,
|
||||
),
|
||||
(get_run_test, open_recipe_file, (f"recipes/{name[0]}/run_test.sh", recipes_path, github_repo), container),
|
||||
(get_run_test, open_recipe_file, (f"recipes/{name}/run_test.sh", recipes_path, github_repo), container),
|
||||
(get_test_from_anaconda, get_anaconda_url, (container, anaconda_channel), container),
|
||||
]:
|
||||
result = try_a_func(*f)
|
||||
if result:
|
||||
return result
|
||||
|
||||
versions = get_alternative_versions(f"recipes/{name[0]}", "meta.yaml", recipes_path, github_repo)
|
||||
for version in versions:
|
||||
result = try_a_func(get_commands_from_yaml, open_recipe_file, (version, recipes_path, github_repo), container)
|
||||
alt_versions = get_alternative_versions(f"recipes/{name}", "meta.yaml", recipes_path, github_repo)
|
||||
for alt_version in alt_versions:
|
||||
result = try_a_func(
|
||||
get_commands_from_yaml, open_recipe_file, (alt_version, recipes_path, github_repo), container
|
||||
)
|
||||
if result:
|
||||
return result
|
||||
|
||||
versions = get_alternative_versions(f"recipes/{name[0]}", "run_test.sh", recipes_path, github_repo)
|
||||
for version in versions:
|
||||
result = try_a_func(get_run_test, open_recipe_file, (version, recipes_path, github_repo), container)
|
||||
alt_versions = get_alternative_versions(f"recipes/{name}", "run_test.sh", recipes_path, github_repo)
|
||||
for alt_version in alt_versions:
|
||||
result = try_a_func(get_run_test, open_recipe_file, (alt_version, recipes_path, github_repo), container)
|
||||
if result:
|
||||
return result
|
||||
|
||||
versions = find_anaconda_versions(name[0], anaconda_channel)
|
||||
for version in versions:
|
||||
result = try_a_func(get_test_from_anaconda, prepend_anaconda_url, (version,), container)
|
||||
if result:
|
||||
return result
|
||||
url = find_anaconda_download_url(name, version, build=build, anaconda_channel=anaconda_channel)
|
||||
result = try_a_func(get_test_from_anaconda, lambda x: x, (url,), container)
|
||||
if result:
|
||||
return result
|
||||
|
||||
# if everything fails
|
||||
return {"container": container}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@
|
||||
},
|
||||
"homepage": "https://github.com/galaxyproject/galaxy#readme",
|
||||
"dependencies": {
|
||||
"@galaxyproject/galaxy-client": "^24.0.0",
|
||||
"@galaxyproject/galaxy-client": "^25.0.4",
|
||||
"cpy-cli": "^5.0.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
|
||||
@@ -2,7 +2,7 @@ from unittest import SkipTest
|
||||
|
||||
from galaxy.tool_util.deps.mulled.get_tests import (
|
||||
deep_test_search,
|
||||
find_anaconda_versions,
|
||||
find_anaconda_download_url,
|
||||
get_alternative_versions,
|
||||
get_anaconda_url,
|
||||
get_commands_from_yaml,
|
||||
@@ -11,7 +11,6 @@ from galaxy.tool_util.deps.mulled.get_tests import (
|
||||
hashed_test_search,
|
||||
main_test_search,
|
||||
open_recipe_file,
|
||||
prepend_anaconda_url,
|
||||
)
|
||||
from galaxy.util import smart_str
|
||||
from ..util import external_dependency_management
|
||||
@@ -49,11 +48,6 @@ def test_get_anaconda_url():
|
||||
assert url == "https://anaconda.org/bioconda/samtools/1.7/download/linux-64/samtools-1.7-1.tar.bz2"
|
||||
|
||||
|
||||
def test_prepend_anaconda_url():
|
||||
url = prepend_anaconda_url("/bioconda/samtools/0.1.12/download/linux-64/samtools-0.1.12-2.tar.bz2")
|
||||
assert url == "https://anaconda.org/bioconda/samtools/0.1.12/download/linux-64/samtools-0.1.12-2.tar.bz2"
|
||||
|
||||
|
||||
@external_dependency_management
|
||||
def test_get_test_from_anaconda():
|
||||
# test old fashion tar.bz2 package
|
||||
@@ -81,9 +75,20 @@ def test_get_test_from_anaconda():
|
||||
|
||||
|
||||
@external_dependency_management
|
||||
def test_find_anaconda_versions():
|
||||
versions = find_anaconda_versions("2pg_cartesian")
|
||||
assert "/bioconda/2pg_cartesian/1.0.1/download/linux-64/2pg_cartesian-1.0.1-0.tar.bz2" in versions
|
||||
def test_find_anaconda_download_url():
|
||||
download_url = find_anaconda_download_url("2pg_cartesian", "1.0.0")
|
||||
assert download_url is None
|
||||
download_url = find_anaconda_download_url("2pg_cartesian", "1.0.1")
|
||||
assert download_url is not None
|
||||
assert download_url.startswith(
|
||||
"https://api.anaconda.org/download/bioconda/2pg_cartesian/1.0.1/linux-64/2pg_cartesian-1.0.1-"
|
||||
)
|
||||
download_url = find_anaconda_download_url("2pg_cartesian", "1.0.1", "0")
|
||||
assert download_url is not None
|
||||
assert (
|
||||
download_url
|
||||
== "https://api.anaconda.org/download/bioconda/2pg_cartesian/1.0.1/linux-64/2pg_cartesian-1.0.1-0.tar.bz2"
|
||||
)
|
||||
|
||||
|
||||
@external_dependency_management
|
||||
|
||||
Reference in New Issue
Block a user