Skip to content

Commit 5511175

Browse files
committed
Add CKAN file source with import and export support
1 parent 06fd60a commit 5511175

5 files changed

Lines changed: 326 additions & 4 deletions

File tree

client/packages/api-client/src/schema/schema.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13198,7 +13198,8 @@ export interface components {
1319813198
| "iiif"
1319913199
| "mavedb"
1320013200
| "omero"
13201-
| "ssh";
13201+
| "ssh"
13202+
| "ckan";
1320213203
/** Variables */
1320313204
variables?:
1320413205
| (
@@ -25572,7 +25573,8 @@ export interface components {
2557225573
| "iiif"
2557325574
| "mavedb"
2557425575
| "omero"
25575-
| "ssh";
25576+
| "ssh"
25577+
| "ckan";
2557625578
/** Uri Root */
2557725579
uri_root: string;
2557825580
/**

client/src/api/fileSources.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ export const templateTypes: FileSourceTypesDetail = {
107107
icon: faNetworkWired,
108108
message: "This is a file repository plugin that connects with an iRODS server.",
109109
},
110+
ckan: {
111+
icon: faNetworkWired,
112+
message: "This is a repository plugin that connects with a CKAN instance.",
113+
},
110114
};
111115

112116
export const FileSourcesValidFilters = {

lib/galaxy/files/sources/ckan.py

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
from datetime import datetime
2+
from typing import (
3+
Any,
4+
Optional,
5+
Union,
6+
)
7+
from urllib.parse import urlparse
8+
9+
import requests
10+
from fsspec import AbstractFileSystem
11+
12+
from galaxy.files.models import FilesSourceRuntimeContext
13+
from galaxy.files.sources._fsspec import (
14+
CacheOptionsDictType,
15+
FsspecBaseFileSourceConfiguration,
16+
FsspecBaseFileSourceTemplateConfiguration,
17+
FsspecFilesSource,
18+
)
19+
from galaxy.util import DEFAULT_SOCKET_TIMEOUT
20+
from galaxy.util.config_templates import TemplateExpansion
21+
22+
23+
class CKANFileSystem(AbstractFileSystem):
24+
def __init__(self, base_url: str, token: Optional[str] = None, **kwargs: Any):
25+
super().__init__(**kwargs)
26+
self.base_url = base_url.rstrip("/") # prevents double slashes in URLs
27+
self.token = token
28+
29+
def _get_request_headers(self) -> dict[str, str]:
30+
headers = {}
31+
# auth header, only if token is provided
32+
if self.token:
33+
headers["Authorization"] = self.token # raw api token, CKAN doesnt need bearer prefix
34+
return headers
35+
36+
def _raise_for_ckan_error(self, response: requests.Response) -> None:
37+
if response.status_code in (401, 403):
38+
raise PermissionError(f"Access denied (HTTP {response.status_code}).")
39+
if response.status_code == 404:
40+
raise FileNotFoundError(f"Not found (HTTP {response.status_code}): {response.url}")
41+
response.raise_for_status() # any other 4xx/5xx still raises a generic HTTPError
42+
43+
# call ckan action api and return response result
44+
def _get_response(
45+
self,
46+
action: str,
47+
params: Optional[dict[str, Any]] = None,
48+
) -> Any:
49+
url = f"{self.base_url}/api/3/action/{action}"
50+
headers = self._get_request_headers()
51+
response = requests.get(url, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT, params=params)
52+
self._raise_for_ckan_error(response) # raise exception for HTTP errors
53+
payload = response.json()
54+
# ckan could return HTTP 200 even for errors, so check success field in payload
55+
if not payload.get("success", False): # success is False or missing
56+
error = payload.get("error", {})
57+
raise Exception(f"CKAN API call failed: {error.get('message') or error}")
58+
return payload["result"]
59+
60+
# upload a file to an existing dataset as new resource
61+
def _post_resource(self, dataset_id: str, resource_name: str, file_path: str) -> None:
62+
url = f"{self.base_url}/api/3/action/resource_create"
63+
headers = self._get_request_headers()
64+
with open(file_path, "rb") as f:
65+
response = requests.post(
66+
url,
67+
headers=headers,
68+
data={"package_id": dataset_id, "name": resource_name}, # target dataset and display name
69+
files={"upload": (resource_name, f)}, # filename for download and file content
70+
timeout=DEFAULT_SOCKET_TIMEOUT,
71+
)
72+
self._raise_for_ckan_error(response) # raise exception for HTTP errors
73+
payload = response.json()
74+
# ckan could return HTTP 200 even for errors, so check success field in payload
75+
if not payload.get("success", False): # success is False or missing
76+
error = payload.get("error", {})
77+
raise Exception(f"CKAN API call failed: {error.get('message') or error}")
78+
79+
def _list_public_datasets(self) -> list[str]:
80+
# cheap call that returns just the names of public datasets
81+
return self._get_response("package_list")
82+
83+
def _list_private_datasets(self) -> list[str]:
84+
# for listing private datasets, package_search is needed which returns all metadata, not just names
85+
# since package_search paginates results, its needed to loop until all datasets are retrieved
86+
names = []
87+
start = 0
88+
while True:
89+
# if no private datasets just returns empty list
90+
result = self._get_response(
91+
"package_search",
92+
params={
93+
"fq": "private:true", # filter query to return only private datasets
94+
"include_private": True, # without this CKAN hides private datasets
95+
"rows": 1000, # rows is page size, 1000 is max allowed in default CKAN
96+
"start": start, # pagination offset
97+
},
98+
)
99+
names.extend([dataset["name"] for dataset in result["results"]])
100+
# result["count"] is total number of private datasets, tells if all are retrieved
101+
if len(names) >= result["count"]:
102+
break
103+
start += 1000
104+
return names
105+
106+
# lists all datasets that are available to the user
107+
# users private datasets are listed first, then the public ones
108+
def _list_all_datasets(self) -> list[str]:
109+
return self._list_private_datasets() + self._list_public_datasets()
110+
111+
# fetch dataset metadata
112+
def _get_dataset(self, dataset_id: str) -> dict[str, Any]:
113+
return self._get_response("package_show", params={"id": dataset_id})
114+
115+
# extract resources from dataset payload
116+
def _list_resources(self, dataset_id: str) -> list[dict[str, Any]]:
117+
dataset = self._get_dataset(dataset_id)
118+
return dataset.get("resources", [])
119+
120+
def _is_root(self, path: str) -> bool:
121+
return path in ("", "/")
122+
123+
# in case resource name is missing, use id
124+
def _resource_name(self, resource: dict[str, Any]) -> Optional[str]:
125+
return resource.get("name") or resource.get("id")
126+
127+
# returns last modified as a date object, or None if missing/invalid
128+
def _parse_modified(self, value: Optional[str]) -> Optional[datetime]:
129+
if not value:
130+
return None
131+
try:
132+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
133+
except ValueError:
134+
return None
135+
136+
# returns resource entry with optional metadata
137+
def _resource_entry(self, dataset_id: str, resource: dict[str, Any]) -> dict[str, Any]:
138+
size = resource.get("size") or resource.get("filesize")
139+
entry = {
140+
"name": f"/{dataset_id}/{self._resource_name(resource)}",
141+
"type": "file",
142+
"modified": self._parse_modified(resource.get("last_modified") or resource.get("metadata_modified")),
143+
"id": resource.get("id"),
144+
}
145+
# size isnt always known, so this prevents int(None) error
146+
if size is not None:
147+
entry["size"] = size
148+
return entry
149+
150+
# returns dataset entry with optional metadata
151+
def _dataset_entry(self, name: str, dataset: Optional[dict[str, Any]] = None) -> dict[str, Any]:
152+
entry: dict[str, Any] = {"name": f"/{name}", "type": "directory", "size": None}
153+
if dataset:
154+
# adds these parameter in case detail=True
155+
entry["modified"] = self._parse_modified(dataset.get("metadata_modified") or dataset.get("last_modified"))
156+
entry["id"] = dataset.get("id")
157+
return entry
158+
159+
# splits path into dataset_id and optional resource_name
160+
def _split_path(self, path: str) -> tuple[str, Optional[str]]:
161+
parts = path.strip("/").split("/", 1)
162+
dataset_id = parts[0]
163+
resource_name = None # path is /dataset
164+
if len(parts) > 1:
165+
resource_name = parts[1] # path is /dataset/resource
166+
return dataset_id, resource_name
167+
168+
# find a resource by name in a dataset
169+
def _find_resource(self, dataset_id: str, resource_name: str) -> dict[str, Any]:
170+
resources = self._list_resources(dataset_id)
171+
for resource in resources:
172+
if self._resource_name(resource) == resource_name:
173+
return resource
174+
raise FileNotFoundError(f"/{dataset_id}/{resource_name}")
175+
176+
def _is_same_host(self, url: str) -> bool:
177+
return urlparse(url).hostname == urlparse(self.base_url).hostname
178+
179+
# list datasets in root or resources in a dataset
180+
def ls(self, path: str = "", detail: bool = True, **kwargs: Any) -> list[dict[str, Any]] | list[str]:
181+
if not self._is_root(path):
182+
# list resources in dataset
183+
dataset_id, _ = self._split_path(path) # only need dataset_id, resource_name isnt needed
184+
resources = self._list_resources(dataset_id)
185+
if detail:
186+
return [self._resource_entry(dataset_id, r) for r in resources]
187+
return [self._resource_entry(dataset_id, r)["name"] for r in resources]
188+
189+
# list datasets in root
190+
datasets = self._list_all_datasets()
191+
if detail:
192+
return [self._dataset_entry(name) for name in datasets]
193+
return [self._dataset_entry(name)["name"] for name in datasets]
194+
195+
# get dataset or resource metadata
196+
def info(self, path: str, **kwargs: Any) -> dict[str, Any]:
197+
if self._is_root(path):
198+
return {"name": "/", "type": "directory", "size": None}
199+
dataset_id, resource_name = self._split_path(path)
200+
# /dataset
201+
if not resource_name:
202+
return self._dataset_entry(dataset_id, self._get_dataset(dataset_id))
203+
# /dataset/resource
204+
resource = self._find_resource(dataset_id, resource_name)
205+
return self._resource_entry(dataset_id, resource)
206+
207+
# stream resource content using requests, returns a file-like object
208+
def _open(self, path: str, mode: str = "rb", **kwargs: Any):
209+
if mode != "rb":
210+
raise NotImplementedError("Only read binary mode 'rb' is supported")
211+
dataset_id, resource_name = self._split_path(path)
212+
if resource_name is None:
213+
raise FileNotFoundError(path)
214+
resource = self._find_resource(dataset_id, resource_name)
215+
url = resource.get("url") or resource.get("download_url") or None
216+
if not url:
217+
raise FileNotFoundError(path)
218+
# only add the auth token if the resource is hosted on the same domain as the CKAN instance
219+
# to prevent leaking tokens to external URLs
220+
headers = self._get_request_headers() if self._is_same_host(url) else {}
221+
response = requests.get(url, stream=True, headers=headers, timeout=DEFAULT_SOCKET_TIMEOUT)
222+
self._raise_for_ckan_error(response) # raise exception for HTTP errors
223+
response.raw.decode_content = True
224+
return response.raw
225+
226+
227+
class CKANFileSourceTemplateConfiguration(FsspecBaseFileSourceTemplateConfiguration):
228+
endpoint: Union[str, TemplateExpansion]
229+
token: Union[str, TemplateExpansion, None] = None
230+
231+
232+
class CKANFileSourceConfiguration(FsspecBaseFileSourceConfiguration):
233+
endpoint: str
234+
token: Optional[str] = None
235+
236+
237+
class CKANFilesSource(FsspecFilesSource[CKANFileSourceTemplateConfiguration, CKANFileSourceConfiguration]):
238+
plugin_type = "ckan"
239+
required_module = CKANFileSystem
240+
required_package = "requests"
241+
242+
template_config_class = CKANFileSourceTemplateConfiguration
243+
resolved_config_class = CKANFileSourceConfiguration
244+
245+
def _open_fs(
246+
self, context: FilesSourceRuntimeContext[CKANFileSourceConfiguration], cache_options: CacheOptionsDictType
247+
) -> CKANFileSystem:
248+
config = context.config
249+
return CKANFileSystem(base_url=config.endpoint, token=config.token, **cache_options)
250+
251+
def _write_from(
252+
self, target_path: str, native_path: str, context: FilesSourceRuntimeContext[CKANFileSourceConfiguration]
253+
) -> None:
254+
fs = self._open_fs(context, {})
255+
dataset_id, resource_name = fs._split_path(target_path)
256+
if not resource_name:
257+
raise ValueError("Select a dataset as the upload target, not the root.")
258+
fs._post_resource(dataset_id, resource_name, native_path)
259+
260+
261+
__all__ = ("CKANFilesSource",)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
- id: ckan
2+
version: 0
3+
name: CKAN
4+
description: |
5+
[CKAN](https://ckan.org) is an open-source data portal platform for managing and sharing datasets.
6+
This configuration allows you to connect Galaxy to a CKAN instance of your choice.
7+
8+
**Note:** Your **registered email address** will be used to create datasets in CKAN.
9+
variables:
10+
url:
11+
label: CKAN instance endpoint (e.g. https://demo.ckan.org)
12+
type: string
13+
help: |
14+
The endpoint of the CKAN server you are connecting to. This should be the full URL including the protocol
15+
(http or https) and the domain name.
16+
writable:
17+
label: Allow Galaxy to export data to CKAN?
18+
type: boolean
19+
optional: true
20+
default: true
21+
help: |
22+
Allow Galaxy to write data to this CKAN instance. Set it to "Yes" if you want to export data from Galaxy to
23+
CKAN, set it to "No" if you only need to import data from CKAN to Galaxy.
24+
secrets:
25+
token:
26+
label: CKAN Access Token
27+
optional: true
28+
help: |
29+
The personal access token to use to connect to the CKAN instance. You can generate a new token in your CKAN account settings.
30+
This will allow Galaxy to access private datasets if you have the necessary permissions.
31+
configuration:
32+
type: ckan
33+
token: "{{ secrets.token }}"
34+
writable: "{{ variables.writable }}"
35+
endpoint: "{{ variables.url }}"

lib/galaxy/files/templates/models.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"mavedb",
5555
"omero",
5656
"ssh",
57+
"ckan",
5758
]
5859

5960
FileSourceTemplateAlertVariant = Literal[
@@ -535,6 +536,22 @@ class OmeroFileSourceConfiguration(StrictModel):
535536
writable: bool = False
536537

537538

539+
class CKANFileSourceTemplateConfiguration(StrictModel):
540+
type: Literal["ckan"]
541+
endpoint: str | TemplateExpansion
542+
token: str | TemplateExpansion | None = None
543+
writable: bool | TemplateExpansion = True
544+
template_start: str | None = None
545+
template_end: str | None = None
546+
547+
548+
class CKANFileSourceConfiguration(StrictModel):
549+
type: Literal["ckan"]
550+
endpoint: str
551+
token: str | None = None
552+
writable: bool = True
553+
554+
538555
FileSourceTemplateConfiguration = Annotated[
539556
PosixFileSourceTemplateConfiguration
540557
| S3FSFileSourceTemplateConfiguration
@@ -558,7 +575,8 @@ class OmeroFileSourceConfiguration(StrictModel):
558575
| IIIFFileSourceTemplateConfiguration
559576
| MaveDBFileSourceTemplateConfiguration
560577
| OmeroFileSourceTemplateConfiguration
561-
| SshFileSourceTemplateConfiguration,
578+
| SshFileSourceTemplateConfiguration
579+
| CKANFileSourceTemplateConfiguration,
562580
Field(discriminator="type"),
563581
]
564582

@@ -585,7 +603,8 @@ class OmeroFileSourceConfiguration(StrictModel):
585603
| IIIFFileSourceConfiguration
586604
| MaveDBFileSourceConfiguration
587605
| OmeroFileSourceConfiguration
588-
| SshFileSourceConfiguration,
606+
| SshFileSourceConfiguration
607+
| CKANFileSourceConfiguration,
589608
Field(discriminator="type"),
590609
]
591610

@@ -673,6 +692,7 @@ def template_to_configuration(
673692
"mavedb": MaveDBFileSourceConfiguration,
674693
"omero": OmeroFileSourceConfiguration,
675694
"ssh": SshFileSourceConfiguration,
695+
"ckan": CKANFileSourceConfiguration,
676696
}
677697

678698

0 commit comments

Comments
 (0)