From 07dd3236853c0053067267aa7e503e61cede5f34 Mon Sep 17 00:00:00 2001 From: Cayman Williams Date: Mon, 11 Dec 2023 10:56:10 -0700 Subject: [PATCH 1/6] initial orjson replacement --- pyproject.toml | 1 - sync/awsemr.py | 10 ++++----- sync/azuredatabricks.py | 8 ++++---- sync/cli/_databricks.py | 18 ++++++++--------- sync/cli/predictions.py | 27 +++++++++---------------- sync/cli/projects.py | 17 ++++++---------- sync/cli/workspaces.py | 23 +++++++++------------ sync/clients/__init__.py | 10 ++++----- sync/utils/json.py | 23 +++++++++++++++++++++ tests/api/test_predictions.py | 4 ++-- tests/asyncapi/test_asyncpredictions.py | 4 ++-- tests/test_awsdatabricks.py | 9 +++++---- tests/test_awsemr.py | 8 ++++---- 13 files changed, 83 insertions(+), 79 deletions(-) create mode 100644 sync/utils/json.py diff --git a/pyproject.toml b/pyproject.toml index 37aad62..20663e0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,6 @@ dependencies = [ "boto3~=1.26.0", "pydantic~=1.10.0", "httpx~=0.23.0", - "orjson~=3.8.0", "click~=8.1.0", "tenacity==8.2.2", "azure-identity==1.13.0", diff --git a/sync/awsemr.py b/sync/awsemr.py index 42de050..3899145 100644 --- a/sync/awsemr.py +++ b/sync/awsemr.py @@ -12,7 +12,7 @@ from uuid import uuid4 import boto3 as boto -import orjson +import json from dateutil.parser import parse as dateparse from sync import TIME_FORMAT @@ -28,6 +28,7 @@ ProjectError, Response, ) +from syncsparkpy.sync.utils.json import DateTimeEncoderDropMicroseconds logger = logging.getLogger(__name__) @@ -364,7 +365,7 @@ def get_project_cluster_report( # noqa: C901 s3.download_fileobj(parsed_project_url.netloc, config_key, config) return Response( result=( - orjson.loads(config.getvalue().decode()), + json.loads(config.getvalue().decode()), f"s3://{parsed_project_url.netloc}/{log_key}", ) ) @@ -753,10 +754,7 @@ def _upload_object(obj: dict, s3_url: str) -> Response[str]: s3 = boto.client("s3") s3.upload_fileobj( io.BytesIO( - orjson.dumps( - obj, - option=orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS | orjson.OPT_NAIVE_UTC, - ) + bytes(json.dumps(obj, cls=DateTimeEncoderDropMicroseconds), 'utf-8') ), parsed_url.netloc, obj_key, diff --git a/sync/azuredatabricks.py b/sync/azuredatabricks.py index ab5b87d..c6e43ef 100644 --- a/sync/azuredatabricks.py +++ b/sync/azuredatabricks.py @@ -5,7 +5,7 @@ from typing import List, Dict, Type, TypeVar, Optional from urllib.parse import urlparse -import orjson +import json from azure.common.credentials import get_cli_profile from azure.core.exceptions import ClientAuthenticationError from azure.identity import DefaultAzureCredential @@ -266,7 +266,7 @@ def _get_cluster_instances(cluster: dict) -> Response[dict]: ) cluster_instances = ( - orjson.loads(cluster_instances_file_response) + json.loads(cluster_instances_file_response) if cluster_instances_file_response else None ) @@ -371,12 +371,12 @@ def write_file(body: bytes): all_timelines = retired_timelines + list(active_timelines_by_id.values()) write_file( - orjson.dumps( + bytes(json.dumps( { "instances": list(all_vms_by_id.values()), "timelines": all_timelines, } - ) + ), 'utf-8') ) except Exception as e: diff --git a/sync/cli/_databricks.py b/sync/cli/_databricks.py index 0a9b2aa..918488a 100644 --- a/sync/cli/_databricks.py +++ b/sync/cli/_databricks.py @@ -1,7 +1,7 @@ from typing import Tuple import click -import orjson +import json from sync.api.projects import ( create_project_recommendation, @@ -11,6 +11,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import DatabricksComputeType, DatabricksPlanType, Platform, Preference +from sync.utils.json import DateTimeEncoder pass_platform = click.make_pass_decorator(Platform) @@ -202,9 +203,8 @@ def get_recommendation(project: dict, recommendation_id: str): click.echo("Recommendation generation failed.", err=True) else: click.echo( - orjson.dumps( - recommendation, - option=orjson.OPT_INDENT_2 | orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z, + json.dumps( + recommendation, indent=2, cls=DateTimeEncoder, ) ) else: @@ -223,9 +223,8 @@ def get_submission(project: dict, submission_id: str): click.echo("Submission generation failed.", err=True) else: click.echo( - orjson.dumps( - submission, - option=orjson.OPT_INDENT_2 | orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z, + json.dumps( + submission, indent=2, cls=DateTimeEncoder, ) ) else: @@ -277,9 +276,8 @@ def get_cluster_report( config = config_response.result if config: click.echo( - orjson.dumps( - config.dict(exclude_none=True), - option=orjson.OPT_INDENT_2 | orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z, + json.dumps( + config.dict(exclude_none=True), indent=2, cls=DateTimeEncoder, ) ) else: diff --git a/sync/cli/predictions.py b/sync/cli/predictions.py index 2bcf552..0d48512 100644 --- a/sync/cli/predictions.py +++ b/sync/cli/predictions.py @@ -4,7 +4,7 @@ import boto3 as boto import click -import orjson +import json from sync.api.predictions import ( create_prediction, @@ -17,6 +17,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import Platform, Preference +from sync.utils.json import DateTimeEncoderDropMicroseconds @click.group @@ -48,12 +49,12 @@ def generate( parsed_report_arg = urlparse(report) if parsed_report_arg.scheme == "": with open(report) as report_fobj: - report = orjson.loads(report_fobj.read()) + report = json.loads(report_fobj.read()) elif parsed_report_arg.scheme == "s3": s3 = boto.client("s3") report_io = io.BytesIO() s3.download_fileobj(parsed_report_arg.netloc, parsed_report_arg.path.lstrip("/"), report_io) - report = orjson.loads(report_io.getvalue()) + report = json.loads(report_io.getvalue()) else: ctx.fail("Unsupported report argument") @@ -83,12 +84,8 @@ def generate( prediction = prediction_response.result if prediction: click.echo( - orjson.dumps( - prediction, - option=orjson.OPT_INDENT_2 - | orjson.OPT_UTC_Z - | orjson.OPT_NAIVE_UTC - | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + prediction, indent=2, cls = DateTimeEncoderDropMicroseconds ) ) else: @@ -108,12 +105,12 @@ def create(ctx: click.Context, platform: Platform, event_log: str, report: str, parsed_report_arg = urlparse(report) if parsed_report_arg.scheme == "": with open(report) as report_fobj: - report = orjson.loads(report_fobj.read()) + report = json.loads(report_fobj.read()) elif parsed_report_arg.scheme == "s3": s3 = boto.client("s3") report_io = io.BytesIO() s3.download_fileobj(parsed_report_arg.netloc, parsed_report_arg.path.lstrip("/"), report_io) - report = orjson.loads(report_io.getvalue()) + report = json.loads(report_io.getvalue()) else: ctx.fail("Unsupported report argument") @@ -162,12 +159,8 @@ def get(prediction_id: str, preference: Preference): """Retrieve a prediction""" response = get_prediction(prediction_id, preference.value) click.echo( - orjson.dumps( - response.result, - option=orjson.OPT_INDENT_2 - | orjson.OPT_UTC_Z - | orjson.OPT_NAIVE_UTC - | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + response.result, indent=2, cls=DateTimeEncoderDropMicroseconds ) ) diff --git a/sync/cli/projects.py b/sync/cli/projects.py index 1e110b6..bb533fd 100644 --- a/sync/cli/projects.py +++ b/sync/cli/projects.py @@ -1,5 +1,5 @@ import click -import orjson +import json from sync.api.projects import ( create_project, @@ -12,7 +12,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import Preference - +from sync.utils.json import DateTimeEncoderDropMicroseconds @click.group def projects(): @@ -41,9 +41,8 @@ def get(project: dict): project = response.result if project: click.echo( - orjson.dumps( - project, - option=orjson.OPT_INDENT_2 | orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + project, indent=2, cls = DateTimeEncoderDropMicroseconds ) ) else: @@ -184,12 +183,8 @@ def get_latest_prediction(project: dict, preference: Preference): prediction = prediction_response.result if prediction: click.echo( - orjson.dumps( - prediction, - option=orjson.OPT_INDENT_2 - | orjson.OPT_UTC_Z - | orjson.OPT_NAIVE_UTC - | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + prediction, indent=2, cls=DateTimeEncoderDropMicroseconds ) ) else: diff --git a/sync/cli/workspaces.py b/sync/cli/workspaces.py index a3367c9..d14b3bb 100644 --- a/sync/cli/workspaces.py +++ b/sync/cli/workspaces.py @@ -1,10 +1,11 @@ import click -import orjson +import json from sync.api import workspace from sync.cli.util import OPTIONAL_DEFAULT, validate_project from sync.config import API_KEY, DB_CONFIG from sync.models import DatabricksPlanType +from sync.utils.json import DateTimeEncoderDropMicroseconds @click.group @@ -76,9 +77,8 @@ def create_workspace_config( config = response.result if config: click.echo( - orjson.dumps( - config, - option=orjson.OPT_INDENT_2 | orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + config, indent=2, cls=DateTimeEncoderDropMicroseconds ) ) else: @@ -92,9 +92,8 @@ def get_workspace_config(workspace_id: str): config = config_response.result if config: click.echo( - orjson.dumps( - config, - option=orjson.OPT_INDENT_2 | orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + config, indent=2, cls= DateTimeEncoderDropMicroseconds, ) ) else: @@ -173,9 +172,8 @@ def update_workspace_config( config = update_config_response.result if config: click.echo( - orjson.dumps( - config, - option=orjson.OPT_INDENT_2 | orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + config, indent=2, cls=DateTimeEncoderDropMicroseconds, ) ) else: @@ -205,9 +203,8 @@ def reset_webhook_creds(workspace_id: str): result = response.result if result: click.echo( - orjson.dumps( - result, - option=orjson.OPT_INDENT_2 | orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS, + json.dumps( + result, indent=2, cls=DateTimeEncoderDropMicroseconds, ) ) else: diff --git a/sync/clients/__init__.py b/sync/clients/__init__.py index d8ecf62..6ec2639 100644 --- a/sync/clients/__init__.py +++ b/sync/clients/__init__.py @@ -1,23 +1,23 @@ import httpx -import orjson +import json from tenacity import Retrying, TryAgain, stop_after_attempt, wait_exponential_jitter from typing import Tuple, Union, Set from sync import __version__ +from sync.utils.json import DateTimeEncoderDropMicroseconds USER_AGENT = f"Sync Library/{__version__} (syncsparkpy)" def encode_json(obj: dict) -> Tuple[dict, str]: # "%Y-%m-%dT%H:%M:%SZ" - options = orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS | orjson.OPT_NAIVE_UTC - json = orjson.dumps(obj, option=options).decode() + json_obj = json.dumps(obj, cls=DateTimeEncoderDropMicroseconds) return { - "Content-Length": str(len(json)), + "Content-Length": str(len(json_obj)), "Content-Type": "application/json", - }, json + }, json_obj class RetryableHTTPClient: diff --git a/sync/utils/json.py b/sync/utils/json.py new file mode 100644 index 0000000..360dfe5 --- /dev/null +++ b/sync/utils/json.py @@ -0,0 +1,23 @@ +import datetime +from json import JSONEncoder + +class DateTimeEncoder(JSONEncoder): + def default(self, obj): + if isinstance(obj, datetime.datetime): + date = obj + if date.tzinfo is None: + date = date.replace(tzinfo=datetime.timezone.utc) + date = date.isoformat() + date = date.replace("+00:00", "Z") + return date + +class DateTimeEncoderDropMicroseconds(JSONEncoder): + def default(self, obj): + if isinstance(obj, datetime.datetime): + date = obj + date = date.replace(microsecond=0) + if date.tzinfo is None: + date = date.replace(tzinfo=datetime.timezone.utc) + date = date.isoformat() + date = date.replace("+00:00", "Z") + return date \ No newline at end of file diff --git a/tests/api/test_predictions.py b/tests/api/test_predictions.py index bfa96ce..7dc078b 100644 --- a/tests/api/test_predictions.py +++ b/tests/api/test_predictions.py @@ -1,4 +1,4 @@ -import orjson +import json import respx from httpx import Response @@ -52,7 +52,7 @@ def test_get_prediction(): with open("tests/data/predictions_response.json") as predictions_fobj: prediction = [ p - for p in orjson.loads(predictions_fobj.read())["result"] + for p in json.loads(predictions_fobj.read())["result"] if p["prediction_id"] == prediction_id ][0] diff --git a/tests/asyncapi/test_asyncpredictions.py b/tests/asyncapi/test_asyncpredictions.py index 0ed2922..63f19c3 100644 --- a/tests/asyncapi/test_asyncpredictions.py +++ b/tests/asyncapi/test_asyncpredictions.py @@ -1,4 +1,4 @@ -import orjson +import json import pytest import respx from httpx import Response @@ -33,7 +33,7 @@ async def test_generate_prediction(): with open("tests/data/predictions_response.json") as predictions_fobj: prediction = [ p - for p in orjson.loads(predictions_fobj.read())["result"] + for p in json.loads(predictions_fobj.read())["result"] if p["prediction_id"] == prediction_id ][0] mock_router.get(f"/v1/autotuner/predictions/{prediction_id}").mock( diff --git a/tests/test_awsdatabricks.py b/tests/test_awsdatabricks.py index ebceae7..74b6a1b 100644 --- a/tests/test_awsdatabricks.py +++ b/tests/test_awsdatabricks.py @@ -5,7 +5,7 @@ from uuid import uuid4 import boto3 as boto -import orjson +import json from botocore.response import StreamingBody from botocore.stub import Stubber from httpx import Response @@ -14,6 +14,7 @@ from sync.config import DatabricksConf from sync.models import DatabricksAPIError, DatabricksError from sync.models import Response as SyncResponse +from sync.utils.json import DateTimeEncoderDropMicroseconds MOCK_RUN = { "job_id": 12345678910, @@ -812,14 +813,14 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock s3 = boto.client("s3") s3_stubber = Stubber(s3) - mock_cluster_info_bytes = orjson.dumps( + mock_cluster_info_bytes = json.dumps( { "volumes": MOCK_VOLUMES["Volumes"], "instances": [ inst for res in MOCK_INSTANCES["Reservations"] for inst in res["Instances"] ], }, - option=orjson.OPT_UTC_Z | orjson.OPT_OMIT_MICROSECONDS | orjson.OPT_NAIVE_UTC, + cls = DateTimeEncoderDropMicroseconds, ) s3_stubber.add_response( "get_object", @@ -827,7 +828,7 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock "ContentType": "application/octet-stream", "ContentLength": len(mock_cluster_info_bytes), "Body": StreamingBody( - io.BytesIO(mock_cluster_info_bytes), + io.BytesIO(bytes(mock_cluster_info_bytes, 'utf_8')), len(mock_cluster_info_bytes), ), }, diff --git a/tests/test_awsemr.py b/tests/test_awsemr.py index 3a98e07..8fbc466 100644 --- a/tests/test_awsemr.py +++ b/tests/test_awsemr.py @@ -1,7 +1,7 @@ from unittest.mock import Mock, patch import boto3 as boto -import orjson +import json from botocore.stub import ANY, Stubber from dateutil.parser import parse from deepdiff import DeepDiff @@ -20,7 +20,7 @@ def test_create_prediction(create_prediction, get_cluster_report): with open("tests/data/emr-cluster-report.json") as emr_cluster_report_fobj: get_cluster_report.return_value = Response( - result=orjson.loads(emr_cluster_report_fobj.read()) + result=json.loads(emr_cluster_report_fobj.read()) ) prediction_id = "320554b0-3972-4b7c-9e41-c8efdbdc042c" @@ -62,7 +62,7 @@ def test_create_prediction(create_prediction, get_cluster_report): def test_get_cluster_report(): with open("tests/data/emr-cluster-report.json") as emr_cluster_report_fobj: - emr_cluster_report = orjson.loads(emr_cluster_report_fobj.read()) + emr_cluster_report = json.loads(emr_cluster_report_fobj.read()) cluster_id = emr_cluster_report["Cluster"]["Id"] region = emr_cluster_report["Region"] @@ -103,7 +103,7 @@ def test_get_cluster_report(): @patch("sync.awsemr.get_project") def test_get_project_report(get_project, get_cluster_report): with open("tests/data/emr-cluster-report.json") as emr_cluster_report_fobj: - cluster_report = orjson.loads(emr_cluster_report_fobj.read()) + cluster_report = json.loads(emr_cluster_report_fobj.read()) get_cluster_report.return_value = Response(result=cluster_report) get_project.return_value = Response( From 9c96308665299c318fa4beb9c4bab26a16346e53 Mon Sep 17 00:00:00 2001 From: Cayman Williams Date: Mon, 11 Dec 2023 10:58:59 -0700 Subject: [PATCH 2/6] format/lint --- sync/awsemr.py | 6 ++---- sync/azuredatabricks.py | 23 ++++++++++++----------- sync/cli/_databricks.py | 14 ++++++++++---- sync/cli/predictions.py | 14 +++----------- sync/cli/projects.py | 16 +++++----------- sync/cli/workspaces.py | 21 ++++++++++++--------- sync/clients/__init__.py | 5 +++-- sync/utils/json.py | 4 +++- tests/api/test_predictions.py | 1 + tests/asyncapi/test_asyncpredictions.py | 1 + tests/test_awsdatabricks.py | 6 +++--- tests/test_awsemr.py | 2 +- 12 files changed, 56 insertions(+), 57 deletions(-) diff --git a/sync/awsemr.py b/sync/awsemr.py index 3899145..c55761a 100644 --- a/sync/awsemr.py +++ b/sync/awsemr.py @@ -4,6 +4,7 @@ import datetime import io +import json import logging import re from copy import deepcopy @@ -12,7 +13,6 @@ from uuid import uuid4 import boto3 as boto -import json from dateutil.parser import parse as dateparse from sync import TIME_FORMAT @@ -753,9 +753,7 @@ def _upload_object(obj: dict, s3_url: str) -> Response[str]: try: s3 = boto.client("s3") s3.upload_fileobj( - io.BytesIO( - bytes(json.dumps(obj, cls=DateTimeEncoderDropMicroseconds), 'utf-8') - ), + io.BytesIO(bytes(json.dumps(obj, cls=DateTimeEncoderDropMicroseconds), "utf-8")), parsed_url.netloc, obj_key, ) diff --git a/sync/azuredatabricks.py b/sync/azuredatabricks.py index c6e43ef..3516249 100644 --- a/sync/azuredatabricks.py +++ b/sync/azuredatabricks.py @@ -1,11 +1,11 @@ +import json import logging import os import sys from time import sleep -from typing import List, Dict, Type, TypeVar, Optional +from typing import Dict, List, Optional, Type, TypeVar from urllib.parse import urlparse -import json from azure.common.credentials import get_cli_profile from azure.core.exceptions import ClientAuthenticationError from azure.identity import DefaultAzureCredential @@ -266,9 +266,7 @@ def _get_cluster_instances(cluster: dict) -> Response[dict]: ) cluster_instances = ( - json.loads(cluster_instances_file_response) - if cluster_instances_file_response - else None + json.loads(cluster_instances_file_response) if cluster_instances_file_response else None ) # If this cluster does not have the "Sync agent" configured, attempt a best-effort snapshot of the instances that @@ -371,12 +369,15 @@ def write_file(body: bytes): all_timelines = retired_timelines + list(active_timelines_by_id.values()) write_file( - bytes(json.dumps( - { - "instances": list(all_vms_by_id.values()), - "timelines": all_timelines, - } - ), 'utf-8') + bytes( + json.dumps( + { + "instances": list(all_vms_by_id.values()), + "timelines": all_timelines, + } + ), + "utf-8", + ) ) except Exception as e: diff --git a/sync/cli/_databricks.py b/sync/cli/_databricks.py index 918488a..e0f1eb9 100644 --- a/sync/cli/_databricks.py +++ b/sync/cli/_databricks.py @@ -1,7 +1,7 @@ +import json from typing import Tuple import click -import json from sync.api.projects import ( create_project_recommendation, @@ -204,7 +204,9 @@ def get_recommendation(project: dict, recommendation_id: str): else: click.echo( json.dumps( - recommendation, indent=2, cls=DateTimeEncoder, + recommendation, + indent=2, + cls=DateTimeEncoder, ) ) else: @@ -224,7 +226,9 @@ def get_submission(project: dict, submission_id: str): else: click.echo( json.dumps( - submission, indent=2, cls=DateTimeEncoder, + submission, + indent=2, + cls=DateTimeEncoder, ) ) else: @@ -277,7 +281,9 @@ def get_cluster_report( if config: click.echo( json.dumps( - config.dict(exclude_none=True), indent=2, cls=DateTimeEncoder, + config.dict(exclude_none=True), + indent=2, + cls=DateTimeEncoder, ) ) else: diff --git a/sync/cli/predictions.py b/sync/cli/predictions.py index 0d48512..e2bee79 100644 --- a/sync/cli/predictions.py +++ b/sync/cli/predictions.py @@ -1,10 +1,10 @@ import io +import json from pathlib import Path from urllib.parse import urlparse import boto3 as boto import click -import json from sync.api.predictions import ( create_prediction, @@ -83,11 +83,7 @@ def generate( prediction_response = wait_for_prediction(prediction_id, preference.value) prediction = prediction_response.result if prediction: - click.echo( - json.dumps( - prediction, indent=2, cls = DateTimeEncoderDropMicroseconds - ) - ) + click.echo(json.dumps(prediction, indent=2, cls=DateTimeEncoderDropMicroseconds)) else: click.echo(str(response.error), err=True) else: @@ -158,11 +154,7 @@ def status(prediction_id: str): def get(prediction_id: str, preference: Preference): """Retrieve a prediction""" response = get_prediction(prediction_id, preference.value) - click.echo( - json.dumps( - response.result, indent=2, cls=DateTimeEncoderDropMicroseconds - ) - ) + click.echo(json.dumps(response.result, indent=2, cls=DateTimeEncoderDropMicroseconds)) @predictions.command diff --git a/sync/cli/projects.py b/sync/cli/projects.py index bb533fd..4aef167 100644 --- a/sync/cli/projects.py +++ b/sync/cli/projects.py @@ -1,6 +1,7 @@ -import click import json +import click + from sync.api.projects import ( create_project, delete_project, @@ -14,6 +15,7 @@ from sync.models import Preference from sync.utils.json import DateTimeEncoderDropMicroseconds + @click.group def projects(): """Sync project commands""" @@ -40,11 +42,7 @@ def get(project: dict): response = get_project(project["id"]) project = response.result if project: - click.echo( - json.dumps( - project, indent=2, cls = DateTimeEncoderDropMicroseconds - ) - ) + click.echo(json.dumps(project, indent=2, cls=DateTimeEncoderDropMicroseconds)) else: click.echo(str(response.error), err=True) @@ -182,10 +180,6 @@ def get_latest_prediction(project: dict, preference: Preference): prediction_response = get_prediction(project["id"], preference) prediction = prediction_response.result if prediction: - click.echo( - json.dumps( - prediction, indent=2, cls=DateTimeEncoderDropMicroseconds - ) - ) + click.echo(json.dumps(prediction, indent=2, cls=DateTimeEncoderDropMicroseconds)) else: click.echo(str(prediction_response.error), err=True) diff --git a/sync/cli/workspaces.py b/sync/cli/workspaces.py index d14b3bb..69e67c1 100644 --- a/sync/cli/workspaces.py +++ b/sync/cli/workspaces.py @@ -1,6 +1,7 @@ -import click import json +import click + from sync.api import workspace from sync.cli.util import OPTIONAL_DEFAULT, validate_project from sync.config import API_KEY, DB_CONFIG @@ -76,11 +77,7 @@ def create_workspace_config( ) config = response.result if config: - click.echo( - json.dumps( - config, indent=2, cls=DateTimeEncoderDropMicroseconds - ) - ) + click.echo(json.dumps(config, indent=2, cls=DateTimeEncoderDropMicroseconds)) else: click.echo(str(response.error), err=True) @@ -93,7 +90,9 @@ def get_workspace_config(workspace_id: str): if config: click.echo( json.dumps( - config, indent=2, cls= DateTimeEncoderDropMicroseconds, + config, + indent=2, + cls=DateTimeEncoderDropMicroseconds, ) ) else: @@ -173,7 +172,9 @@ def update_workspace_config( if config: click.echo( json.dumps( - config, indent=2, cls=DateTimeEncoderDropMicroseconds, + config, + indent=2, + cls=DateTimeEncoderDropMicroseconds, ) ) else: @@ -204,7 +205,9 @@ def reset_webhook_creds(workspace_id: str): if result: click.echo( json.dumps( - result, indent=2, cls=DateTimeEncoderDropMicroseconds, + result, + indent=2, + cls=DateTimeEncoderDropMicroseconds, ) ) else: diff --git a/sync/clients/__init__.py b/sync/clients/__init__.py index 6ec2639..e027a78 100644 --- a/sync/clients/__init__.py +++ b/sync/clients/__init__.py @@ -1,7 +1,8 @@ -import httpx import json +from typing import Set, Tuple, Union + +import httpx from tenacity import Retrying, TryAgain, stop_after_attempt, wait_exponential_jitter -from typing import Tuple, Union, Set from sync import __version__ from sync.utils.json import DateTimeEncoderDropMicroseconds diff --git a/sync/utils/json.py b/sync/utils/json.py index 360dfe5..eb342f7 100644 --- a/sync/utils/json.py +++ b/sync/utils/json.py @@ -1,6 +1,7 @@ import datetime from json import JSONEncoder + class DateTimeEncoder(JSONEncoder): def default(self, obj): if isinstance(obj, datetime.datetime): @@ -11,6 +12,7 @@ def default(self, obj): date = date.replace("+00:00", "Z") return date + class DateTimeEncoderDropMicroseconds(JSONEncoder): def default(self, obj): if isinstance(obj, datetime.datetime): @@ -20,4 +22,4 @@ def default(self, obj): date = date.replace(tzinfo=datetime.timezone.utc) date = date.isoformat() date = date.replace("+00:00", "Z") - return date \ No newline at end of file + return date diff --git a/tests/api/test_predictions.py b/tests/api/test_predictions.py index 7dc078b..15bb900 100644 --- a/tests/api/test_predictions.py +++ b/tests/api/test_predictions.py @@ -1,4 +1,5 @@ import json + import respx from httpx import Response diff --git a/tests/asyncapi/test_asyncpredictions.py b/tests/asyncapi/test_asyncpredictions.py index 63f19c3..2625583 100644 --- a/tests/asyncapi/test_asyncpredictions.py +++ b/tests/asyncapi/test_asyncpredictions.py @@ -1,4 +1,5 @@ import json + import pytest import respx from httpx import Response diff --git a/tests/test_awsdatabricks.py b/tests/test_awsdatabricks.py index 74b6a1b..bbf0805 100644 --- a/tests/test_awsdatabricks.py +++ b/tests/test_awsdatabricks.py @@ -1,11 +1,11 @@ import copy import io +import json from datetime import datetime from unittest.mock import Mock, patch from uuid import uuid4 import boto3 as boto -import json from botocore.response import StreamingBody from botocore.stub import Stubber from httpx import Response @@ -820,7 +820,7 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock inst for res in MOCK_INSTANCES["Reservations"] for inst in res["Instances"] ], }, - cls = DateTimeEncoderDropMicroseconds, + cls=DateTimeEncoderDropMicroseconds, ) s3_stubber.add_response( "get_object", @@ -828,7 +828,7 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock "ContentType": "application/octet-stream", "ContentLength": len(mock_cluster_info_bytes), "Body": StreamingBody( - io.BytesIO(bytes(mock_cluster_info_bytes, 'utf_8')), + io.BytesIO(bytes(mock_cluster_info_bytes, "utf_8")), len(mock_cluster_info_bytes), ), }, diff --git a/tests/test_awsemr.py b/tests/test_awsemr.py index 8fbc466..862a341 100644 --- a/tests/test_awsemr.py +++ b/tests/test_awsemr.py @@ -1,7 +1,7 @@ +import json from unittest.mock import Mock, patch import boto3 as boto -import json from botocore.stub import ANY, Stubber from dateutil.parser import parse from deepdiff import DeepDiff From 437e86e5bdec36f773afb600c427a70576b24594 Mon Sep 17 00:00:00 2001 From: Cayman Williams Date: Mon, 11 Dec 2023 11:22:41 -0700 Subject: [PATCH 3/6] fix imports --- sync/awsdatabricks.py | 6 +++--- sync/awsemr.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/sync/awsdatabricks.py b/sync/awsdatabricks.py index b7fcb89..7c83d5b 100644 --- a/sync/awsdatabricks.py +++ b/sync/awsdatabricks.py @@ -5,7 +5,7 @@ import boto3 as boto import botocore -import orjson +import json from botocore.exceptions import ClientError import sync._databricks @@ -273,7 +273,7 @@ def _load_aws_cluster_info(cluster: dict) -> Tuple[Response[dict], Response[dict cluster_info_file_response = _get_cluster_instances_from_dbfs(cluster_info_file_key) cluster_info = ( - orjson.loads(cluster_info_file_response) if cluster_info_file_response else None + json.loads(cluster_info_file_response) if cluster_info_file_response else None ) # If this cluster does not have the "Sync agent" configured, attempt a best-effort snapshot of the instances that @@ -409,7 +409,7 @@ def write_file(body: bytes): all_timelines = retired_timelines + list(active_timelines_by_id.values()) write_file( - orjson.dumps( + json.dumps( { "instances": list(all_inst_by_id.values()), "instance_timelines": all_timelines, diff --git a/sync/awsemr.py b/sync/awsemr.py index c55761a..406e4af 100644 --- a/sync/awsemr.py +++ b/sync/awsemr.py @@ -28,7 +28,7 @@ ProjectError, Response, ) -from syncsparkpy.sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderDropMicroseconds logger = logging.getLogger(__name__) From 8a85809bbd73693784a9cdadbbeb6280b80d3ba5 Mon Sep 17 00:00:00 2001 From: Cayman Williams Date: Mon, 11 Dec 2023 12:27:58 -0700 Subject: [PATCH 4/6] fix a few json dumps calls --- sync/awsdatabricks.py | 17 ++++++++++------- tests/test_awsdatabricks.py | 21 ++++++++++++--------- 2 files changed, 22 insertions(+), 16 deletions(-) diff --git a/sync/awsdatabricks.py b/sync/awsdatabricks.py index 7c83d5b..add98bb 100644 --- a/sync/awsdatabricks.py +++ b/sync/awsdatabricks.py @@ -1,3 +1,4 @@ +import json import logging from time import sleep from typing import List, Tuple @@ -5,7 +6,6 @@ import boto3 as boto import botocore -import json from botocore.exceptions import ClientError import sync._databricks @@ -409,12 +409,15 @@ def write_file(body: bytes): all_timelines = retired_timelines + list(active_timelines_by_id.values()) write_file( - json.dumps( - { - "instances": list(all_inst_by_id.values()), - "instance_timelines": all_timelines, - "volumes": list(recorded_volumes_by_id.values()), - } + bytes( + json.dumps( + { + "instances": list(all_inst_by_id.values()), + "instance_timelines": all_timelines, + "volumes": list(recorded_volumes_by_id.values()), + } + ), + "utf-8", ) ) except Exception as e: diff --git a/tests/test_awsdatabricks.py b/tests/test_awsdatabricks.py index bbf0805..2d76c5d 100644 --- a/tests/test_awsdatabricks.py +++ b/tests/test_awsdatabricks.py @@ -813,14 +813,17 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock s3 = boto.client("s3") s3_stubber = Stubber(s3) - mock_cluster_info_bytes = json.dumps( - { - "volumes": MOCK_VOLUMES["Volumes"], - "instances": [ - inst for res in MOCK_INSTANCES["Reservations"] for inst in res["Instances"] - ], - }, - cls=DateTimeEncoderDropMicroseconds, + mock_cluster_info_bytes = bytes( + json.dumps( + { + "volumes": MOCK_VOLUMES["Volumes"], + "instances": [ + inst for res in MOCK_INSTANCES["Reservations"] for inst in res["Instances"] + ], + }, + cls=DateTimeEncoderDropMicroseconds, + ), + "utf-8", ) s3_stubber.add_response( "get_object", @@ -828,7 +831,7 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock "ContentType": "application/octet-stream", "ContentLength": len(mock_cluster_info_bytes), "Body": StreamingBody( - io.BytesIO(bytes(mock_cluster_info_bytes, "utf_8")), + io.BytesIO(mock_cluster_info_bytes), len(mock_cluster_info_bytes), ), }, From f97ebc090f91dfae4a73ec1aea9133e35212e2ea Mon Sep 17 00:00:00 2001 From: Cayman Williams Date: Tue, 12 Dec 2023 01:44:47 -0700 Subject: [PATCH 5/6] bump version --- sync/__init__.py | 2 +- sync/cli/awsemr.py | 11 ++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/sync/__init__.py b/sync/__init__.py index 57e6122..c44a4a9 100644 --- a/sync/__init__.py +++ b/sync/__init__.py @@ -1,4 +1,4 @@ """Library for leveraging the power of Sync""" -__version__ = "0.5.1" +__version__ = "0.5.2" TIME_FORMAT = "%Y-%m-%dT%H:%M:%SZ" diff --git a/sync/cli/awsemr.py b/sync/cli/awsemr.py index d0b90c0..95dbe4b 100644 --- a/sync/cli/awsemr.py +++ b/sync/cli/awsemr.py @@ -1,14 +1,15 @@ +import json from io import TextIOWrapper from typing import Dict import click -import orjson from sync import awsemr from sync.api.predictions import get_prediction from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import Platform, Preference +from sync.utils.json import DateTimeEncoder @click.group @@ -34,7 +35,7 @@ def run_job_flow(job_flow: TextIOWrapper, project: dict = None, region: str = No """Run a job flow JOB_FLOW is a file containing the RunJobFlow request object""" - job_flow_obj = orjson.loads(job_flow.read()) + job_flow_obj = json.loads(job_flow.read()) run_response = awsemr.run_and_record_job_flow( job_flow_obj, project["id"] if project else None, region @@ -125,11 +126,7 @@ def get_cluster_report(cluster_id: str, region: str = None): config_response = awsemr.get_cluster_report(cluster_id, region) config = config_response.result if config: - click.echo( - orjson.dumps( - config, option=orjson.OPT_INDENT_2 | orjson.OPT_NAIVE_UTC | orjson.OPT_UTC_Z - ) - ) + click.echo(json.dumps(config, indent=2, cls=DateTimeEncoder)) else: click.echo(f"Failed to create prediction. {config_response.error}", err=True) From 0333e51f7d2379fb784b2d8f5f281d2ecc8cd60d Mon Sep 17 00:00:00 2001 From: Cayman Williams Date: Fri, 15 Dec 2023 15:42:09 -0700 Subject: [PATCH 6/6] Fix monitor cluster methods --- sync/awsdatabricks.py | 4 +++- sync/awsemr.py | 6 ++++-- sync/azuredatabricks.py | 4 +++- sync/cli/_databricks.py | 8 ++++---- sync/cli/awsemr.py | 4 ++-- sync/cli/predictions.py | 8 +++++--- sync/cli/projects.py | 6 +++--- sync/cli/workspaces.py | 10 +++++----- sync/clients/__init__.py | 4 ++-- sync/utils/json.py | 15 +++++++++++++-- tests/test_awsdatabricks.py | 4 ++-- 11 files changed, 46 insertions(+), 27 deletions(-) diff --git a/sync/awsdatabricks.py b/sync/awsdatabricks.py index add98bb..2ad9f5c 100644 --- a/sync/awsdatabricks.py +++ b/sync/awsdatabricks.py @@ -56,6 +56,7 @@ Response, ) from sync.utils.dbfs import format_dbfs_filepath, write_dbfs_file +from sync.utils.json import DefaultDateTimeEncoder __all__ = [ "get_access_report", @@ -415,7 +416,8 @@ def write_file(body: bytes): "instances": list(all_inst_by_id.values()), "instance_timelines": all_timelines, "volumes": list(recorded_volumes_by_id.values()), - } + }, + cls=DefaultDateTimeEncoder, ), "utf-8", ) diff --git a/sync/awsemr.py b/sync/awsemr.py index 406e4af..a8f0035 100644 --- a/sync/awsemr.py +++ b/sync/awsemr.py @@ -28,7 +28,7 @@ ProjectError, Response, ) -from sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderNaiveUTCDropMicroseconds logger = logging.getLogger(__name__) @@ -753,7 +753,9 @@ def _upload_object(obj: dict, s3_url: str) -> Response[str]: try: s3 = boto.client("s3") s3.upload_fileobj( - io.BytesIO(bytes(json.dumps(obj, cls=DateTimeEncoderDropMicroseconds), "utf-8")), + io.BytesIO( + bytes(json.dumps(obj, cls=DateTimeEncoderNaiveUTCDropMicroseconds), "utf-8") + ), parsed_url.netloc, obj_key, ) diff --git a/sync/azuredatabricks.py b/sync/azuredatabricks.py index 3516249..d9218d5 100644 --- a/sync/azuredatabricks.py +++ b/sync/azuredatabricks.py @@ -59,6 +59,7 @@ Response, ) from sync.utils.dbfs import format_dbfs_filepath, write_dbfs_file +from sync.utils.json import DefaultDateTimeEncoder __all__ = [ "get_access_report", @@ -374,7 +375,8 @@ def write_file(body: bytes): { "instances": list(all_vms_by_id.values()), "timelines": all_timelines, - } + }, + cls=DefaultDateTimeEncoder, ), "utf-8", ) diff --git a/sync/cli/_databricks.py b/sync/cli/_databricks.py index e0f1eb9..c0857e6 100644 --- a/sync/cli/_databricks.py +++ b/sync/cli/_databricks.py @@ -11,7 +11,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import DatabricksComputeType, DatabricksPlanType, Platform, Preference -from sync.utils.json import DateTimeEncoder +from sync.utils.json import DateTimeEncoderNaiveUTC pass_platform = click.make_pass_decorator(Platform) @@ -206,7 +206,7 @@ def get_recommendation(project: dict, recommendation_id: str): json.dumps( recommendation, indent=2, - cls=DateTimeEncoder, + cls=DateTimeEncoderNaiveUTC, ) ) else: @@ -228,7 +228,7 @@ def get_submission(project: dict, submission_id: str): json.dumps( submission, indent=2, - cls=DateTimeEncoder, + cls=DateTimeEncoderNaiveUTC, ) ) else: @@ -283,7 +283,7 @@ def get_cluster_report( json.dumps( config.dict(exclude_none=True), indent=2, - cls=DateTimeEncoder, + cls=DateTimeEncoderNaiveUTC, ) ) else: diff --git a/sync/cli/awsemr.py b/sync/cli/awsemr.py index 95dbe4b..26cbc1a 100644 --- a/sync/cli/awsemr.py +++ b/sync/cli/awsemr.py @@ -9,7 +9,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import Platform, Preference -from sync.utils.json import DateTimeEncoder +from sync.utils.json import DateTimeEncoderNaiveUTC @click.group @@ -126,7 +126,7 @@ def get_cluster_report(cluster_id: str, region: str = None): config_response = awsemr.get_cluster_report(cluster_id, region) config = config_response.result if config: - click.echo(json.dumps(config, indent=2, cls=DateTimeEncoder)) + click.echo(json.dumps(config, indent=2, cls=DateTimeEncoderNaiveUTC)) else: click.echo(f"Failed to create prediction. {config_response.error}", err=True) diff --git a/sync/cli/predictions.py b/sync/cli/predictions.py index e2bee79..8575ffb 100644 --- a/sync/cli/predictions.py +++ b/sync/cli/predictions.py @@ -17,7 +17,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import Platform, Preference -from sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderNaiveUTCDropMicroseconds @click.group @@ -83,7 +83,9 @@ def generate( prediction_response = wait_for_prediction(prediction_id, preference.value) prediction = prediction_response.result if prediction: - click.echo(json.dumps(prediction, indent=2, cls=DateTimeEncoderDropMicroseconds)) + click.echo( + json.dumps(prediction, indent=2, cls=DateTimeEncoderNaiveUTCDropMicroseconds) + ) else: click.echo(str(response.error), err=True) else: @@ -154,7 +156,7 @@ def status(prediction_id: str): def get(prediction_id: str, preference: Preference): """Retrieve a prediction""" response = get_prediction(prediction_id, preference.value) - click.echo(json.dumps(response.result, indent=2, cls=DateTimeEncoderDropMicroseconds)) + click.echo(json.dumps(response.result, indent=2, cls=DateTimeEncoderNaiveUTCDropMicroseconds)) @predictions.command diff --git a/sync/cli/projects.py b/sync/cli/projects.py index 4aef167..cf483d2 100644 --- a/sync/cli/projects.py +++ b/sync/cli/projects.py @@ -13,7 +13,7 @@ from sync.cli.util import validate_project from sync.config import CONFIG from sync.models import Preference -from sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderNaiveUTCDropMicroseconds @click.group @@ -42,7 +42,7 @@ def get(project: dict): response = get_project(project["id"]) project = response.result if project: - click.echo(json.dumps(project, indent=2, cls=DateTimeEncoderDropMicroseconds)) + click.echo(json.dumps(project, indent=2, cls=DateTimeEncoderNaiveUTCDropMicroseconds)) else: click.echo(str(response.error), err=True) @@ -180,6 +180,6 @@ def get_latest_prediction(project: dict, preference: Preference): prediction_response = get_prediction(project["id"], preference) prediction = prediction_response.result if prediction: - click.echo(json.dumps(prediction, indent=2, cls=DateTimeEncoderDropMicroseconds)) + click.echo(json.dumps(prediction, indent=2, cls=DateTimeEncoderNaiveUTCDropMicroseconds)) else: click.echo(str(prediction_response.error), err=True) diff --git a/sync/cli/workspaces.py b/sync/cli/workspaces.py index 69e67c1..4d25297 100644 --- a/sync/cli/workspaces.py +++ b/sync/cli/workspaces.py @@ -6,7 +6,7 @@ from sync.cli.util import OPTIONAL_DEFAULT, validate_project from sync.config import API_KEY, DB_CONFIG from sync.models import DatabricksPlanType -from sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderNaiveUTCDropMicroseconds @click.group @@ -77,7 +77,7 @@ def create_workspace_config( ) config = response.result if config: - click.echo(json.dumps(config, indent=2, cls=DateTimeEncoderDropMicroseconds)) + click.echo(json.dumps(config, indent=2, cls=DateTimeEncoderNaiveUTCDropMicroseconds)) else: click.echo(str(response.error), err=True) @@ -92,7 +92,7 @@ def get_workspace_config(workspace_id: str): json.dumps( config, indent=2, - cls=DateTimeEncoderDropMicroseconds, + cls=DateTimeEncoderNaiveUTCDropMicroseconds, ) ) else: @@ -174,7 +174,7 @@ def update_workspace_config( json.dumps( config, indent=2, - cls=DateTimeEncoderDropMicroseconds, + cls=DateTimeEncoderNaiveUTCDropMicroseconds, ) ) else: @@ -207,7 +207,7 @@ def reset_webhook_creds(workspace_id: str): json.dumps( result, indent=2, - cls=DateTimeEncoderDropMicroseconds, + cls=DateTimeEncoderNaiveUTCDropMicroseconds, ) ) else: diff --git a/sync/clients/__init__.py b/sync/clients/__init__.py index e027a78..ff1a5d8 100644 --- a/sync/clients/__init__.py +++ b/sync/clients/__init__.py @@ -5,7 +5,7 @@ from tenacity import Retrying, TryAgain, stop_after_attempt, wait_exponential_jitter from sync import __version__ -from sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderNaiveUTCDropMicroseconds USER_AGENT = f"Sync Library/{__version__} (syncsparkpy)" @@ -13,7 +13,7 @@ def encode_json(obj: dict) -> Tuple[dict, str]: # "%Y-%m-%dT%H:%M:%SZ" - json_obj = json.dumps(obj, cls=DateTimeEncoderDropMicroseconds) + json_obj = json.dumps(obj, cls=DateTimeEncoderNaiveUTCDropMicroseconds) return { "Content-Length": str(len(json_obj)), diff --git a/sync/utils/json.py b/sync/utils/json.py index eb342f7..fc783ca 100644 --- a/sync/utils/json.py +++ b/sync/utils/json.py @@ -2,7 +2,17 @@ from json import JSONEncoder -class DateTimeEncoder(JSONEncoder): +class DefaultDateTimeEncoder(JSONEncoder): + # this copies orjson's default behavior when serializing datetimes + def default(self, obj): + if isinstance(obj, datetime.datetime): + date = obj + date = date.isoformat() + return date + + +class DateTimeEncoderNaiveUTC(JSONEncoder): + # this copies orjson's behavior when used with the options OPT_UTC_Z and OPT_NAIVE_UTC def default(self, obj): if isinstance(obj, datetime.datetime): date = obj @@ -13,7 +23,8 @@ def default(self, obj): return date -class DateTimeEncoderDropMicroseconds(JSONEncoder): +class DateTimeEncoderNaiveUTCDropMicroseconds(JSONEncoder): + # this copies orjson's behavior when used with the options OPT_OMIT_MICROSECONDS, OPT_UTC_Z, and OPT_NAIVE_UTC def default(self, obj): if isinstance(obj, datetime.datetime): date = obj diff --git a/tests/test_awsdatabricks.py b/tests/test_awsdatabricks.py index 2d76c5d..c586d2b 100644 --- a/tests/test_awsdatabricks.py +++ b/tests/test_awsdatabricks.py @@ -14,7 +14,7 @@ from sync.config import DatabricksConf from sync.models import DatabricksAPIError, DatabricksError from sync.models import Response as SyncResponse -from sync.utils.json import DateTimeEncoderDropMicroseconds +from sync.utils.json import DateTimeEncoderNaiveUTCDropMicroseconds MOCK_RUN = { "job_id": 12345678910, @@ -821,7 +821,7 @@ def test_create_prediction_for_run_success_with_cluster_instance_file(respx_mock inst for res in MOCK_INSTANCES["Reservations"] for inst in res["Instances"] ], }, - cls=DateTimeEncoderDropMicroseconds, + cls=DateTimeEncoderNaiveUTCDropMicroseconds, ), "utf-8", )