Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 50 additions & 6 deletions obiba_opal/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ def __init__(self, server=None):
self.base_url = self.__ensure_entry("Opal address", server)
self.id = None
self.rid = None
self.profile = None
self.version = None

def __del__(self):
self.close()
Expand Down Expand Up @@ -67,13 +69,14 @@ def buildWithCertificate(cls, server, cert, key, no_ssl_verify: bool = False):
:param key - private key (must be named as 'privatekey.pem')
:param no_ssl_verify - if True, the SSL certificate is not verified
(not recommended)
:return: the client instance
"""

client = cls(server)
if client.base_url.startswith("https:"):
client.session.verify = not no_ssl_verify
client.session.cert = (cert, key)

client.init()
return client

@classmethod
Expand All @@ -86,6 +89,7 @@ def buildWithAuthentication(cls, server, user, password, no_ssl_verify: bool = F
:param password - user password
:param no_ssl_verify - if True, the SSL certificate is not verified
(not recommended)
:return: the client instance
"""
client = cls(server)
if client.base_url.startswith("https:"):
Expand Down Expand Up @@ -114,6 +118,7 @@ def buildWithToken(cls, server, token, no_ssl_verify: bool = False):
:param token - token key
:param no_ssl_verify - if True, the SSL certificate is not verified
(not recommended)
:return: the client instance
"""
client = cls(server)
if client.base_url.startswith("https:"):
Expand All @@ -134,6 +139,7 @@ def credentials(self, user, password):

:param user - username
:param password - user password
:return: the client instance
"""
u = self.__ensure_entry("User name", user)
p = self.__ensure_entry("Password", password, True)
Expand All @@ -147,17 +153,18 @@ def token(self, token):
Creates the authorization header and attempts to input the required token

:param token - token key
:return: the client instance
"""
tk = self.__ensure_entry("Token", token, True)
return self.header("X-Opal-Auth", tk)
self.header("X-Opal-Auth", tk)
self.init()
return self

def init_otp(self):
"""
Checks if an OTP is needed and if yes, prompts the user for the security code
"""
request = self.new_request()
profile_url = "/system/subject-profile/_current"
response = request.accept_json().get().resource(profile_url).send()
response = self.init()
if response.code == 401:
auth_header = response.get_header("WWW-Authenticate")
if auth_header:
Expand All @@ -166,7 +173,41 @@ def init_otp(self):
val = input("Enter 6-digits code: ")
# validate code and get the opalsid cookie for further requests
request = self.new_request()
request.header(otp_header, val).accept_json().get().resource(profile_url).send()
self.init(request.header(otp_header, val))

def init(self, request=None):
"""
Initializes the client profile and version information by calling the system profile endpoint

:return: the response of the profile endpoint
"""
_request = self.new_request() if request is None else request
profile_url = "/system/subject-profile/_current"
response = _request.accept_json().get().resource(profile_url).send()
if response.code == 200:
self.profile = response.from_json()
self.version = response.get_version()
return response

def compare_version(self, version):
"""
Compares the Opal version with the provided version and raises an exception if the Opal version is lower than the provided version

:param version - the version to compare with
:return: -1 if the Opal version is lower than the provided version, 0 if they are equal, 1 if the Opal version is higher than the provided version
Comment thread
ymarcon marked this conversation as resolved.
:raises Exception: if the Opal version is not initialized
"""
if self.version is None:
raise Exception("Opal version is not initialized")
clean_version = self.version.split("-")[0] # Remove any suffix like "-SNAPSHOT"
opal_version = tuple(map(int, clean_version.split(".")))
required_version = tuple(map(int, version.split(".")))
if opal_version < required_version:
return -1
elif opal_version > required_version:
return 1
else:
return 0

def verify(self, value):
"""
Expand Down Expand Up @@ -500,6 +541,9 @@ def get_header(self, header: str) -> str | None:
def get_location(self):
return self.get_header("Location")

def get_version(self):
return self.get_header("X-Opal-Version")

def extract_cookie_value(self, name: str) -> str | None:
if "set-cookie" in self.response.headers:
if isinstance(self.response.headers["set-cookie"], str):
Expand Down
55 changes: 52 additions & 3 deletions obiba_opal/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@
Opal file management.
"""

import obiba_opal.core as core
import json
import sys
import os

import obiba_opal.core as core
from obiba_opal.system import TaskService


Comment thread
ymarcon marked this conversation as resolved.
class FileService:
"""
Expand Down Expand Up @@ -90,7 +93,11 @@ def download_file(self, path: str, fd: int | os.PathLike, download_password: str
:param fd: The file descriptor or path to the destination file
:param download_password: The password to use to encrypt the
downloaded zip archive
:raises Exception: If the file is not readable
"""
info = self.file_info(path)
if not info["readable"]:
raise Exception(f"File {path} is not readable")
request = self.client.new_request()
request.fail_on_error()

Expand All @@ -100,8 +107,42 @@ def download_file(self, path: str, fd: int | os.PathLike, download_password: str
file = FileService.OpalFile(path)
fp = os.fdopen(fd, "wb") if isinstance(fd, int) else fd

request.get().resource(file.get_ws()).accept("*/*").header("X-File-Key", download_password).send(fp)
fp.flush()
if self.client.compare_version("5.7.0") < 0:
# File download before Opal 5.7.0
request.get().resource(file.get_ws()).accept("*/*").header("X-File-Key", download_password).send(fp)
fp.flush()
else:
# File download with Opal 5.7.0 or later
Comment thread
ymarcon marked this conversation as resolved.
if download_password or info["type"] == "FOLDER":
# File to be bundled as zip archive
options = file.make_bundle_options(download_password)
bundle_request = self.client.new_request().fail_on_error()
if self.verbose:
bundle_request.verbose()
response = bundle_request.post().resource("/shell/commands/_file-bundle").accept_json().content_type_json().content(json.dumps(options)).send()
task = response.from_json()
task_service = TaskService(self.client)
Comment thread
ymarcon marked this conversation as resolved.
try:
status = task_service.wait_task(task["id"], True)
if status == "SUCCEEDED":
# Task succeeded, downloading file
download_request = self.client.new_request().fail_on_error()
if self.verbose:
download_request.verbose()
download_request.get().resource(f"/shell/command/{task['id']}/_result").accept("*/*").send(fp)
fp.flush()
else:
raise Exception(f"File bundle task failed with status: {status}")
finally:
# Ensure task is deleted in any case to avoid leaving pending tasks on the server
delete_request = self.client.new_request().fail_on_error()
if self.verbose:
delete_request.verbose()
delete_request.delete().resource(f"/shell/command/{task['id']}").send()
else:
# File to be downloaded as is
request.get().resource(file.get_ws()).accept("*/*").header("X-File-Key", download_password).send(fp)
fp.flush()

def upload_file(self, upload: str, path: str):
"""
Expand Down Expand Up @@ -167,3 +208,11 @@ def get_meta_ws(self):

def get_ws(self):
return f"/files{self.path}"

def make_bundle_options(self, download_password):
options = {
"paths": [self.path]
}
if download_password:
options["password"] = download_password
return options
22 changes: 12 additions & 10 deletions obiba_opal/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,18 +452,20 @@ def cancel_task(self, id: str | int):
request.content("CANCELED")
request.put().resource(f"/shell/command/{id}/status").send()

def wait_task(self, id: str | int):
def wait_task(self, id: str | int, silently: bool = False):
""" Wait for the task to complete or being canceled, and return its status. """
task = self.get_task(id)
while task["status"] not in ["SUCCEEDED", "CANCELED", "FAILED"]:
if "progress" in task:
progress = task["progress"]
if "message" in progress:
sys.stdout.write("\r\033[K" + str(progress["percent"]) + "% " + progress["message"])
while task["status"] not in ["SUCCEEDED", "CANCELED", "FAILED", "CANCEL_PENDING"]:
Comment thread
ymarcon marked this conversation as resolved.
if not silently:
if "progress" in task:
progress = task["progress"]
if "message" in progress:
sys.stdout.write("\r\033[K" + str(progress["percent"]) + "% " + progress["message"])
else:
sys.stdout.write("\r\033[K" + str(progress["percent"]) + "%")
else:
sys.stdout.write("\r\033[K" + str(progress["percent"]) + "%")
else:
sys.stdout.write(".")
sys.stdout.flush()
sys.stdout.write(".")
sys.stdout.flush()
time.sleep(1)
task = self.get_task(id)
return task["status"]
Expand Down
7 changes: 6 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "obiba-opal"
version = "6.0.2"
version = "6.1.0"
description = "OBiBa/Opal python client."
authors = [
{name = "Yannick Marcon", email = "yannick.marcon@obiba.org"}
Expand Down Expand Up @@ -52,3 +52,8 @@ build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["obiba_opal"]

[tool.pytest.ini_options]
markers = [
"integration: marks tests that require a running Opal instance",
]
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading