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
7 changes: 7 additions & 0 deletions CHANGE.txt
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
LabKey Python Client API News
+++++++++++

What's New in the LabKey 4.2.0 package
==============================

*Release date: 02/26/2026*
- Add get_queries to query module
- Accessible via API wrapper as api.query.get_queries

What's New in the LabKey 4.1.0 package
==============================

Expand Down
78 changes: 76 additions & 2 deletions labkey/query.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,12 @@

############################################################################
"""

import functools
from typing import List, Literal, NotRequired, TextIO, TypedDict

from .server_context import ServerContext
from .utils import waf_encode
from .utils import waf_encode, transform_options

_default_timeout = 60 * 5 # 5 minutes

Expand All @@ -60,6 +61,7 @@ class Pagination:
ALL = "all"
NONE = "none"


# TODO: Provide filter generators.
#
# There are some inconsistencies between the different filter types with multiple values,
Expand Down Expand Up @@ -143,7 +145,6 @@ class Types:
ARRAY_ISEMPTY = "arrayisempty"
ARRAY_ISNOTEMPTY = "arrayisnotempty"


# Table/Query-wise operators
Q = "q"

Expand Down Expand Up @@ -704,6 +705,53 @@ def move_rows(
)


get_queries_fields = [
"schema_name",
"include_columns",
"include_system_queries",
"include_title",
"include_user_queries",
"include_view_data_url",
"query_detail_columns",
]


def get_queries(
server_context: ServerContext,
schema_name: str,
container_path: str = None,
timeout=_default_timeout,
**kwargs,
) -> dict:
"""
:param server_context: A LabKey server context. See utils.create_server_context.
:param schema_name: schema of table
:param container_path: folder path if not already part of server_context
:param timeout: Request timeout in seconds (defaults to 300s)
:param kwargs: Optional parameters supported by this API:
include_columns: boolean, if set to False, information about the available columns in this query will not be
included in the results. Default is True.
include_system_queries: boolean, if set to false, system-defined queries will not be included in the results.
Default is True.
include_title: boolean, if set to False, no custom query titles will be included. Instead, titles will be
identical to names. Default is True.
include_user_queries: boolean, if set to False, user-defined queries will not be included in the results.
Default is True.
include_view_data_url: boolean, if set to False, view data URLs will not be included in the results.
Default is True.
query_detail_columns: boolean, if set to True, and includeColumns is set to True, information about the
available columns will be the same details as specified by getQueryDetails for columns. Defaults to False.
:return: dict
"""
url = server_context.build_url("query", "getQueries.api", container_path=container_path)
payload = {"schemaName": schema_name}

if len(kwargs) > 0:
payload = {**payload, **transform_options(kwargs, get_queries_fields)}

return server_context.make_request(url, payload, timeout=timeout)


class QueryWrapper:
"""
Wrapper for all of the API methods exposed in the query module. Used by the APIWrapper class.
Expand Down Expand Up @@ -939,3 +987,29 @@ def move_rows(
audit_user_comment,
timeout,
)

@functools.wraps(get_queries)
def get_queries(
self,
schema_name: str,
container_path: str = None,
include_columns: bool = None,
include_system_queries: bool = None,
include_title: bool = None,
include_user_queries: bool = None,
include_view_data_url: bool = None,
query_detail_columns: bool = None,
timeout=_default_timeout,
):
return get_queries(
self.server_context,
schema_name,
container_path,
timeout,
include_columns=include_columns,
include_system_queries=include_system_queries,
include_title=include_title,
include_user_queries=include_user_queries,
include_view_data_url=include_view_data_url,
query_detail_columns=query_detail_columns,
)
2 changes: 0 additions & 2 deletions labkey/server_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,6 @@ def __init__(
self._session = requests.Session()
self._session.headers.update({"User-Agent": f"LabKey Python API/{client_version}"})

print(f"User Agent header: LabKey Python API/{client_version}")

if self._use_ssl:
self._scheme = "https://"
if not self._verify_ssl:
Expand Down
1 change: 1 addition & 0 deletions labkey/storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@

############################################################################
"""

import functools
from dataclasses import dataclass

Expand Down
32 changes: 32 additions & 0 deletions labkey/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from functools import wraps
from datetime import date, datetime
from base64 import b64encode
from typing import List
from urllib import parse


Expand Down Expand Up @@ -91,3 +92,34 @@ def waf_encode(value: str) -> str:
if value:
return "/*{{base64/x-www-form-urlencoded/wafText}}*/" + btoa(encode_uri_component(value))
return value


def snake_to_camel(value: str):
"""
Converts a snake_case string to camelCase
"""
if not value:
return value

if "_" not in value:
return value

parts = [part for part in value.split("_") if part]

if len(parts) == 0:
return ""

return parts[0].lower() + "".join([part.title() for part in parts[1:]])


def transform_options(options: dict, expected_keys: List[str]) -> dict:
"""
Converts a dict with snake_case keys to a new dict with camelCase keys, only copying keys from expected_keys
"""
transformed_options = {}

for key, item in options.items():
if key in expected_keys:
transformed_options[snake_to_camel(key)] = item

return transformed_options
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name="labkey"
version = "4.1.0"
version = "4.2.0"
description = "Python client API for LabKey Server"
dependencies = ["requests>=2.32.5"]
readme = "README.md"
Expand Down
1 change: 1 addition & 0 deletions samples/query_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
"""
Examples using the Query.py API
"""

from labkey.api_wrapper import APIWrapper
from labkey.exceptions import (
RequestError,
Expand Down
46 changes: 44 additions & 2 deletions test/integration/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -346,8 +346,8 @@ def test_api_save_rows(api: APIWrapper, blood_sample_type_fixture, tissue_sample
assert resp["committed"] == False
assert resp["errorCount"] == 1
assert (
"SampleID or Name is required for sample on row 2" in
resp["result"][0]["errors"]["exception"]
"SampleID or Name is required for sample on row 2"
in resp["result"][0]["errors"]["exception"]
)

# Fix the first command by specifying a name for the sample
Expand Down Expand Up @@ -411,3 +411,45 @@ def test_api_save_rows(api: APIWrapper, blood_sample_type_fixture, tissue_sample
assert resp["result"][2]["rowsAffected"] == 1
assert resp["result"][2]["rows"][0]["rowid"] == first_tissue_row_id
assert resp["result"][2]["rows"][0]["receiveddate"] == "2025-07-07 12:34:56.000"


expected_fields = {
"canEdit",
"canEditSharedViews",
"columns",
"hidden",
"inherit",
"isIncludedForLookups",
"isInherited",
"isMetadataOverrideable",
"isUserDefined",
"moduleName",
"name",
"snapshot",
"title",
"viewDataUrl",
}


def test_get_queries(api: APIWrapper):
resp = api.query.get_queries("core")

all_queries_count = len(resp["queries"])
assert set(resp.keys()) == {"schemaName", "queries"}
assert resp["schemaName"] == "core"
assert all_queries_count > 0
assert set(resp["queries"][0].keys()) == set(expected_fields)

resp = api.query.get_queries("core", include_system_queries=False, include_user_queries=False)

assert set(resp.keys()) == {"schemaName", "queries"}
assert resp["schemaName"] == "core"
# By excluding system queries, and user queries, we should have no queries
assert len(resp["queries"]) == 0

resp = api.query.get_queries("core", include_columns=False, include_view_data_url=False)

assert set(resp["queries"][0].keys()) == expected_fields - {
"columns",
"viewDataUrl",
}
Loading