Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose bindings to ArAsset, to support performing OpenAsset(resolvedPath) from Python #3318

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
2 changes: 2 additions & 0 deletions pxr/usd/ar/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ pxr_library(ar

PYMODULE_CPPFILES
module.cpp
wrapAsset.cpp
wrapAssetInfo.cpp
wrapDefaultResolverContext.cpp
wrapDefaultResolver.cpp
Expand All @@ -83,6 +84,7 @@ pxr_test_scripts(
testenv/testArAssetInfo.py
testenv/testArAdvancedAPI.py
testenv/testArDefaultResolver.py
testenv/testArOpenAsset.py
testenv/testArPackageUtils.py
testenv/testArResolvedPath.py
testenv/testArResolverContext.py
Expand Down
1 change: 1 addition & 0 deletions pxr/usd/ar/module.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ PXR_NAMESPACE_USING_DIRECTIVE

TF_WRAP_MODULE
{
TF_WRAP(Asset);
TF_WRAP(AssetInfo);
TF_WRAP(ResolvedPath);
TF_WRAP(Timestamp);
Expand Down
185 changes: 185 additions & 0 deletions pxr/usd/ar/testenv/testArOpenAsset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
#!/pxrpythonsubst
#
# Copyright 2024 Pixar
#
# Licensed under the terms set forth in the LICENSE.txt file available at
# https://openusd.org/license.
#
import json
import os
import tempfile
import unittest

from pxr import Ar

class TestArOpenAsset(unittest.TestCase):

def setUp(self) -> None:
# Create a temporary directory containing a JSON test file, along with
# a binary file:
self._temp_dir = tempfile.TemporaryDirectory()
self._json_file_path = os.path.join(self._temp_dir.name, 'text.json')
self._binary_file_path = os.path.join(self._temp_dir.name, 'binary.bin')

# Write some sample JSON data to the test file.
#
# NOTE: The included UTF-8 string is represented by the following byte
# sequence:
# * 'H', 'e', 'l', 'l', 'o', ',', and '!' are all 1-byte characters
# (ASCII).
# * '世' and '界' are 3-byte characters (East Asian characters).
# * '🌍' is a 4-byte character (emoji representing "Earth Globe
# Europe-Africa").
#
# The overall byte sequence for this UTF-8 string is:
# * "Hello, \\u4e16\\u754c! \\ud83c\\udf0d"
self._json_data = {
'name': 'example',
'value': 1234,
'utf-8': 'Hello, 世界! 🌍',
'child-object': {
'key': 'value',
},
}
with open(self._json_file_path, 'w') as json_file:
json.dump(self._json_data, json_file)

# Write some sample binary data to the test file, including characters
# that would result in UTF-8 decoding errors such as:
# > UnicodeDecodeError: 'utf-8' codec can't decode byte 0xc3 in
# > position 0: invalid continuation byte
#
# Equivalent string of the following byte representation: 'ÃÇéあ',
# where:
# * 'Ã' represents an 'A' with tilde in UTF-8, but can represent a
# different character in Latin-1.
# * 'Ç' represents a 'C' with cedilla in Latin-1, although would be
# different in UTF-8.
# * 'é' represents the byte 0xE9 in ISO-8859-1 (Latin-1), although
# in UTF-8, 0xE9 is not a valid single byte but part of a
# multi-byte sequence.
# * 'あ' is represented by the byte 0x82A0 in Shift JIS.
self._binary_data = b'\xc3\xc7\xe9\x82A0'
with open(self._binary_file_path, 'wb') as binary_file:
binary_file.write(self._binary_data)

def tearDown(self) -> None:
# Cleanup the temporary directory:
self._temp_dir.cleanup()

def _get_resolved_text_filepath(self) -> Ar.ResolvedPath:
"""
Return the resolved path of the Attribute referencing the JSON file,
located in the in-memory test Stage.
"""
return Ar.ResolvedPath(self._json_file_path)

def _get_resolved_binary_filepath(self) -> Ar.ResolvedPath:
"""
Return the resolved path of the Attribute referencing the JSON file,
located in the in-memory test Stage.
"""
return Ar.ResolvedPath(self._binary_file_path)

def test_ar_asset_can_be_opened_from_resolver(self) -> None:
"""
Validate that the test attribute referencing a JSON file has the
interface of a `pxr.Ar.Asset`.
"""
json_file = self._get_resolved_text_filepath()
json_asset = Ar.GetResolver().OpenAsset(resolvedPath=json_file)

self.assertIsInstance(json_asset, Ar.Asset)

def test_ar_asset_has_expected_content_size(self) -> None:
"""
Validate that the referenced `pxr.Ar.Asset` has a size comparable
with the the content of the sample JSON data serialized to the
temporary directory.
"""
json_file = self._get_resolved_text_filepath()
json_asset = Ar.GetResolver().OpenAsset(resolvedPath=json_file)

expected_json_content_size = len(json.dumps(self._json_data))
self.assertEqual(json_asset.GetSize(), expected_json_content_size)

def test_ar_asset_buffer_can_be_read_to_a_valid_buffer(self) -> None:
"""
Validate that the referenced `pxr.Ar.Asset` content matches the
actual JSON file in the temporary test directory, by reading it into a
valid buffer.
"""
json_file = self._get_resolved_text_filepath()
json_asset = Ar.GetResolver().OpenAsset(resolvedPath=json_file)
asset_size = json_asset.GetSize()

buffer = bytearray(asset_size)
size_read = json_asset.Read(buffer, asset_size, 0)

self.assertEqual(buffer.decode(), json.dumps(self._json_data))
self.assertEqual(size_read, asset_size)

def test_ar_asset_buffer_can_be_read_to_a_valid_buffer_of_the_given_length(self) -> None:
"""
Validate that the referenced `pxr.Ar.Asset` content matches the
actual JSON file in the temporary test directory, by reading it into a
valid buffer and reading it up to a given length.
"""
json_file = self._get_resolved_text_filepath()
json_asset = Ar.GetResolver().OpenAsset(resolvedPath=json_file)
asset_size = json_asset.GetSize()

# Read the first half of the file content:
buffer_size = asset_size // 2
buffer = bytearray(buffer_size)
size_read = json_asset.Read(buffer, buffer_size, 0)

self.assertEqual(buffer.decode(),
json.dumps(self._json_data)[:buffer_size])
self.assertEqual(size_read, buffer_size)

# Read the second half of the file content:
buffer_size = asset_size - buffer_size
buffer = bytearray(buffer_size)
size_read = json_asset.Read(buffer, buffer_size, asset_size // 2)

self.assertEqual(buffer.decode(),
json.dumps(self._json_data)[buffer_size:])
self.assertEqual(size_read, buffer_size)

def test_ar_asset_buffer_raises_an_error_when_reading_to_an_invalid_buffer(self) -> None:
"""
Validate that an Error is raised when attempting to read the
`pxr.Ar.Asset` into an invalid buffer.
"""
json_file = self._get_resolved_text_filepath()
json_asset = Ar.GetResolver().OpenAsset(resolvedPath=json_file)

invalid_buffer = ''

with self.assertRaises(TypeError) as e:
json_asset.Read(invalid_buffer, json_asset.GetSize(), 0)
self.assertEqual(str(e.exception),
'Object does not support buffer interface')

def test_ar_asset_buffer_raises_an_error_if_provided_buffer_is_of_insufficient_size(self) -> None:
"""
Validate that an Error is raised when attempting to read the
`pxr.Ar.Asset` into a buffer of insufficient size.
"""
json_file = self._get_resolved_text_filepath()
json_asset = Ar.GetResolver().OpenAsset(resolvedPath=json_file)

# Create a buffer that is 1 element short of being able to hold the
# asset in its entirety, in order to ensure potential "off by 1" limits
# are correctly handled:
buffer = bytearray(json_asset.GetSize() - 1)

with self.assertRaises(ValueError) as e:
json_asset.Read(buffer, json_asset.GetSize(), 0)
self.assertEqual(str(e.exception),
'Provided buffer is of insufficient size to hold the requested data size')


if __name__ == '__main__':
unittest.main()
57 changes: 57 additions & 0 deletions pxr/usd/ar/wrapAsset.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
//
// Copyright 2024 Pixar
//
// Licensed under the terms set forth in the LICENSE.txt file available at
// https://openusd.org/license.
//
#include <boost/python/class.hpp>
#include <boost/python/str.hpp>

#include "pxr/pxr.h"
#include "pxr/base/tf/pyUtils.h"
#include "pxr/usd/ar/asset.h"

using namespace boost::python;

PXR_NAMESPACE_USING_DIRECTIVE

static size_t
_Read(const ArAsset& self, boost::python::object& buffer, size_t count, size_t offset)
{
// Extract the raw pointer from the Python buffer object, and confirm it supports the buffer interface:
PyObject* pyObject = buffer.ptr();
if (!PyObject_CheckBuffer(pyObject)) {
TfPyThrowTypeError("Object does not support buffer interface");
}

// Confirm the provided Python buffer object can be written to:
Py_buffer pyBuffer;
if (PyObject_GetBuffer(pyObject, &pyBuffer, PyBUF_WRITABLE) < 0) {
TfPyThrowTypeError("Unable to get writable buffer from object");
}

// Validate that the provided Python buffer has sufficient size to hold the requested data:
if (size_t(pyBuffer.len) < count) {
PyBuffer_Release(&pyBuffer);
TfPyThrowValueError("Provided buffer is of insufficient size to hold the requested data size");
}

// Proceed to read the given number of elements from the ArAsset, starting at the given offset:
size_t result = self.Read(pyBuffer.buf, count, offset);

PyBuffer_Release(&pyBuffer);
return result;
}

void
wrapAsset()
{
// Bindings for "ArAsset":
class_<ArAsset, std::shared_ptr<ArAsset>, boost::noncopyable>
("Asset", no_init)

.def("GetSize", &ArAsset::GetSize)
.def("Read", &_Read,
(arg("buffer"), arg("count"), arg("offset")))
;
}
2 changes: 2 additions & 0 deletions pxr/usd/ar/wrapResolver.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ wrapResolver()
(args("assetPath"), args("resolvedPath")))
.def("GetModificationTimestamp", &This::GetModificationTimestamp,
(args("assetPath"), args("resolvedPath")))
.def("OpenAsset", &This::OpenAsset,
(args("resolvedPath")))
.def("GetExtension", &This::GetExtension,
args("assetPath"))

Expand Down
Loading