diff --git a/src/atlas4py/CMakeLists.txt b/src/atlas4py/CMakeLists.txt index 90c1963..c0fa4ac 100644 --- a/src/atlas4py/CMakeLists.txt +++ b/src/atlas4py/CMakeLists.txt @@ -81,7 +81,10 @@ endif() ### Python bindings module atlas4py -nanobind_add_module(_atlas4py _atlas4py.cpp) +nanobind_add_module(_atlas4py + _atlas4py.cpp + _atlas4py_Config.cpp +) target_link_libraries(_atlas4py PUBLIC atlas) target_compile_definitions(_atlas4py PRIVATE ATLAS4PY_VERSION_STRING=${PROJECT_VERSION_FULL}) diff --git a/src/atlas4py/_atlas4py.cpp b/src/atlas4py/_atlas4py.cpp index 3cdf243..5ec4d63 100644 --- a/src/atlas4py/_atlas4py.cpp +++ b/src/atlas4py/_atlas4py.cpp @@ -40,6 +40,8 @@ namespace pluto { #include "eckit/value/Value.h" #include "eckit/config/Configuration.h" +#include "_atlas4py_Config.hpp" + namespace nb = ::nanobind; using namespace atlas; @@ -151,77 +153,6 @@ void atlasInitialise() { // the atlas library and deleting it may cause issues. } -nb::object toPyObject( eckit::Configuration const& v ); -nb::object toPyObject( eckit::Configuration const& v, std::string const& key ); - -nb::object toPyObject(bool v) { - return nb::bool_(v); -} -nb::object toPyObject(long v) { - return nb::int_(v); -} -nb::object toPyObject(double v) { - return nb::float_(v); -} -nb::object toPyObject(std::string const& v) { - return nb::str(v.c_str()); -} -template -nb::object toPyObject( std::vector const& v ) { - nb::list ret; - for ( auto const& val : v ) { - ret.append( toPyObject( val ) ); - } - return ret; -} - -nb::object toPyObject( eckit::Configuration const& v, std::string const& key ) { - if ( v.isSubConfiguration ( key ) ) { - return toPyObject( v.getSubConfiguration( key ) ); - } - else if (v.isBoolean( key )) { - return toPyObject( v.getBool( key ) ); - } - else if (v.isIntegral( key )) { - return toPyObject( v.getLong( key ) ); - } - else if (v.isFloatingPoint( key )) { - return toPyObject( v.getDouble( key ) ); - } - else if (v.isString( key )) { - return toPyObject( v.getString( key ) ); - } - else if (v.isSubConfigurationList( key )) { - std::vector subconfigs = v.getSubConfigurations( key ); - return toPyObject( subconfigs ); - } - else if (v.isIntegralList( key )) { - std::vector values = v.getLongVector( key ); - return toPyObject( values ); - } - else if (v.isFloatingPointList( key )) { - std::vector values = v.getDoubleVector( key ); - return toPyObject( values ); - } - else if (v.isStringList( key )) { - std::vector values = v.getStringVector( key ); - return toPyObject( values ); - } - else if (v.isBooleanList( key )) { - throw std::out_of_range( "boolean lists not supported for key " + key ); - } - else { - throw std::out_of_range( "type of value unsupported for key " + key ); - } -} - -nb::object toPyObject( eckit::Configuration const& v ) { - nb::dict ret; - for ( auto const& key : v.keys()) { - ret[ key.c_str() ] = toPyObject( v, key ); - } - return ret; -} int get_nb_device_type( const void* ptr ) { if (pluto::is_pinned(ptr)) { @@ -294,6 +225,8 @@ NB_MODULE( _atlas4py, m ) { .def("_finalise", atlas::finalise); m.attr("__version__") = STRINGIFY(ATLAS4PY_VERSION_STRING); + atlas4py::bind_Config( m ); + nb::class_( m, "PointLonLat" ) .def( nb::init(), "lon"_a, "lat"_a ) .def_prop_ro( "lon", nb::overload_cast<>( &PointLonLat::lon, nb::const_ ) ) @@ -311,7 +244,7 @@ NB_MODULE( _atlas4py, m ) { nb::class_( m, "Projection" ) .def( "__repr__", []( Projection const& p ) { - return "_atlas4py.Projection("_s + nb::str( toPyObject( p.spec() ) ) + ")"_s; + return "_atlas4py.Projection("_s + nb::str( atlas4py::make_object( p.spec() ) ) + ")"_s; } ); nb::class_( m, "Domain" ) @@ -320,7 +253,7 @@ NB_MODULE( _atlas4py, m ) { .def_prop_ro( "units", &Domain::units ) .def( "__repr__", []( Domain const& d ) { if (d) { - return nb::str("_atlas4py.Domain("_s + nb::str( toPyObject( d.spec() ) ) + ")"_s); + return nb::str("_atlas4py.Domain("_s + nb::str( atlas4py::make_object( d.spec() ) ) + ")"_s); } return nb::str("_atlas4py.Domain()"_s); } ); @@ -335,13 +268,13 @@ NB_MODULE( _atlas4py, m ) { .def_prop_ro( "projection", &Grid::projection ) .def_prop_ro( "domain", &Grid::domain ) .def( "__repr__", - []( Grid const& g ) { return "_atlas4py.Grid("_s + nb::str( toPyObject( g.spec() ) ) + ")"_s; } ); + []( Grid const& g ) { return "_atlas4py.Grid("_s + nb::str( atlas4py::make_object( g.spec() ) ) + ")"_s; } ); nb::class_( m, "Spacing" ) .def( "__len__", &grid::Spacing::size ) .def( "__getitem__", &grid::Spacing::operator[]) .def( "__repr__", []( grid::Spacing const& spacing ) { - return "_atlas4py.Spacing("_s + nb::str( toPyObject( spacing.spec() ) ) + ")"_s; + return "_atlas4py.Spacing("_s + nb::str( atlas4py::make_object( spacing.spec() ) ) + ")"_s; } ); nb::class_( m, "LinearSpacing" ) .def( nb::init(), "start"_a, "stop"_a, "N"_a, "endpoint_included"_a = true ); @@ -374,41 +307,6 @@ NB_MODULE( _atlas4py, m ) { .def_prop_ro( "regular", &StructuredGrid::regular ) .def_prop_ro( "periodic", &StructuredGrid::periodic ); - nb::class_( m, "eckit.Configuration" ); - - nb::class_( m, "eckit.LocalConfiguration" ); - - // TODO This is a duplicate of metadata below (because same base class) - nb::class_( m, "Config" ) - .def( nb::init() ) - .def( "__setitem__", - []( util::Config& config, std::string const& key, nb::object value ) { - if ( nb::isinstance( value ) ) - config.set( key, nb::cast( value ) ); - else if ( nb::isinstance( value ) ) - config.set( key, nb::cast( value ) ); - else if ( nb::isinstance( value ) ) - config.set( key, nb::cast( value ) ); - else if ( nb::isinstance( value ) ) - config.set( key, nb::cast( value ) ); - else - throw std::out_of_range( "type of value unsupported" ); - } ) - .def( "__getitem__", - []( util::Config& config, std::string const& key ) -> nb::object { - if ( !config.has( key ) ) - throw std::out_of_range( "key <" + key + "> could not be found" ); - - // TODO: We have to query metadata.get() even though this should - // not be done (see comment in Config::get). We cannot - // avoid this right now because otherwise we cannot query - // the type of the underlying data. - return toPyObject( config, key ); - } ) - .def( "__repr__", []( util::Config const& config ) { - return "_atlas4py.Config("_s + nb::str( toPyObject( config ) ) + ")"_s; - } ); - nb::class_( m, "Field" ) .def_prop_ro( "name", &Field::name ) .def_prop_ro( "strides", &Field::strides ) @@ -569,34 +467,9 @@ NB_MODULE( _atlas4py, m ) { .def_prop_ro( "cells", &functionspace::CellColumns::cells ) .def_prop_ro( "valid", &functionspace::CellColumns::valid ); - nb::class_( m, "Metadata" ) - .def_prop_ro( "keys", &util::Metadata::keys ) - .def( "__setitem__", - []( util::Metadata& metadata, std::string const& key, nb::object value ) { - if ( nb::isinstance( value ) ) - metadata.set( key, nb::cast(value) ); - else if ( nb::isinstance( value ) ) - metadata.set( key, nb::cast(value) ); - else if ( nb::isinstance( value ) ) - metadata.set( key, nb::cast(value) ); - else if ( nb::isinstance( value ) ) - metadata.set( key, nb::cast(value) ); - else - throw std::out_of_range( "type of value unsupported" ); - } ) - .def( "__getitem__", - []( util::Metadata& metadata, std::string const& key ) -> nb::object { - if ( !metadata.has( key ) ) - throw std::out_of_range( "key <" + key + "> could not be found" ); - - // TODO: We have to query metadata.get() even though this should - // not be done (see comment in Config::get). We cannot - // avoid this right now because otherwise we cannot query - // the type of the underlying data. - return toPyObject( metadata, key ); - } ) + nb::class_( m, "Metadata" ) .def( "__repr__", []( util::Metadata const& metadata ) { - return "_atlas4py.Metadata("_s + nb::str( toPyObject( metadata ) ) + ")"_s; + return "_atlas4py.Metadata("_s + nb::str( atlas4py::make_object( metadata ) ) + ")"_s; } ); nb::class_ topology( m, "Topology" ); diff --git a/src/atlas4py/_atlas4py_Config.cpp b/src/atlas4py/_atlas4py_Config.cpp new file mode 100644 index 0000000..c8f9b81 --- /dev/null +++ b/src/atlas4py/_atlas4py_Config.cpp @@ -0,0 +1,241 @@ +#include "_atlas4py_Config.hpp" + +#include +#include +#include + +#include +#include +#include + +#include "eckit/config/Configuration.h" +#include "eckit/config/YAMLConfiguration.h" +#include "eckit/filesystem/PathName.h" + +#include "atlas/util/Config.h" + +namespace nb = ::nanobind; + +namespace { + +nb::object _toPyObject( eckit::Configuration const& v ); +nb::object _toPyObject( eckit::Configuration const& v, std::string& key ); + +nb::object _toPyObject(bool v) { + return nb::bool_(v); +} +nb::object _toPyObject(long v) { + return nb::int_(v); +} +nb::object _toPyObject(double v) { + return nb::float_(v); +} +nb::object _toPyObject(std::string const& v) { + return nb::str(v.c_str()); +} +template +nb::object _toPyObject( std::vector const& v ) { + nb::list ret; + for ( auto const& val : v ) { + ret.append( _toPyObject( val ) ); + } + return ret; +} + +nb::object _toPyObject( eckit::Configuration const& v, std::string const& key ) { + if ( v.isSubConfiguration ( key ) ) { + return _toPyObject( v.getSubConfiguration( key ) ); + } + else if (v.isBoolean( key )) { + return _toPyObject( v.getBool( key ) ); + } + else if (v.isIntegral( key )) { + return _toPyObject( v.getLong( key ) ); + } + else if (v.isFloatingPoint( key )) { + return _toPyObject( v.getDouble( key ) ); + } + else if (v.isString( key )) { + return _toPyObject( v.getString( key ) ); + } + else if (v.isSubConfigurationList( key )) { + std::vector subconfigs = v.getSubConfigurations( key ); + return _toPyObject( subconfigs ); + } + else if (v.isIntegralList( key )) { + std::vector values = v.getLongVector( key ); + return _toPyObject( values ); + } + else if (v.isFloatingPointList( key )) { + std::vector values = v.getDoubleVector( key ); + return _toPyObject( values ); + } + else if (v.isStringList( key )) { + std::vector values = v.getStringVector( key ); + return _toPyObject( values ); + } + else if (v.isBooleanList( key )) { + throw std::out_of_range( "boolean lists not supported for key " + key ); + } + else { + throw std::out_of_range( "type of value unsupported for key " + key ); + } +} + +nb::object _toPyObject( eckit::Configuration const& v ) { + nb::dict ret; + for ( auto const& key : v.keys()) { + ret[ key.c_str() ] = _toPyObject( v, key ); + } + return ret; +} + + +void config_set( eckit::LocalConfiguration& config, const std::string& key, nb::handle value ) { + auto py_type_str = [](nb::handle h) -> std::string { + nb::object type_obj = nb::borrow(Py_TYPE(h.ptr())); + nb::object name = type_obj.attr("__name__"); + return nb::cast(name); + }; + + if (nb::isinstance(value)) { + config.set(key, nb::cast(value)); + } else if (nb::isinstance(value)) { + config.set(key, nb::cast(value)); + } else if (nb::isinstance(value)) { + config.set(key, nb::cast(value)); + } else if (nb::isinstance(value)) { + config.set(key, nb::cast(value)); + } else if (nb::isinstance(value)) { + nb::object seq = nb::cast(value); + size_t n = nb::len(seq); + if (n == 0) { + config.set(key, std::vector{}); + return; + } + auto elem = seq[0]; + auto handle_sequence = [&](auto&& conv) { + using VecType = std::vector>; + VecType vec; + for (size_t i = 0; i < n; ++i) vec.push_back(conv(seq[i])); + config.set(key, vec); + }; + if (nb::isinstance(elem)) { + std::vector vec; + for (size_t i = 0; i < n; ++i) vec.push_back( nb::cast(seq[i]) ? 1 : 0 ); + config.set(key, vec); + } else if (nb::isinstance(elem)) { + handle_sequence([](nb::handle v) { return nb::cast(v); }); + } else if (nb::isinstance(elem)) { + handle_sequence([](nb::handle v) { return nb::cast(v); }); + } else if (nb::isinstance(elem)) { + handle_sequence([](nb::handle v) { return nb::cast(v); }); + } else if (nb::isinstance(elem)) { + std::vector vec; + for (size_t i = 0; i < n; ++i) { + eckit::LocalConfiguration subconfig; + nb::object mapping = nb::cast(seq[i]); + for (nb::handle item : mapping.attr("keys")()) { + config_set(subconfig, nb::cast(item), mapping[item]); + } + vec.push_back(subconfig); + } + config.set(key, vec); + } else { + throw std::out_of_range("Unsupported sequence element type for key '" + key + "': got type '" + py_type_str(elem) + "'"); + } + } else if (nb::isinstance(value)) { + eckit::LocalConfiguration subconfig; + nb::object mapping = nb::cast(value); + for (nb::handle item : mapping.attr("keys")()) { + config_set(subconfig, nb::cast(item), mapping[item]); + } + config.set(key, subconfig); + } else { + throw std::out_of_range("type of value unsupported for key '" + key + "': got type '" + py_type_str(value) + "'"); + } +} + +std::string to_lowercase(const std::string& str) { + std::string lowercase_str = str; + std::transform(lowercase_str.begin(), lowercase_str.end(), lowercase_str.begin(), + [](unsigned char c) { return std::tolower(c); }); + return lowercase_str; +} + +} // namespace + +nb::object atlas4py::make_object( eckit::Configuration const& v ) { + return _toPyObject( v ); +} + + +atlas::util::Config atlas4py::make_Config( nb::kwargs const& kwargs ) { + atlas::util::Config config; + for( const auto& pair : kwargs ) { + const auto key = nb::cast(pair.first); + const auto& value = pair.second; + config_set(config, key, value); + } + return config; +} + +void atlas4py::bind_Config( nb::module_& m ) { + using namespace nanobind::literals; + + nb::class_( m, "eckit.Configuration" ) + .def( "keys", &eckit::Configuration::keys ) + .def( "__contains__", + []( eckit::Configuration const& config, std::string const& key ) { + return config.has( key ); + } ) + .def( "__getitem__", + []( eckit::Configuration& config, std::string const& key ) -> nb::object { + if ( !config.has( key ) ) + throw std::out_of_range( "key <" + key + "> could not be found" ); + return _toPyObject( config, key ); + } ) + .def( "__iter__", + []( eckit::Configuration const& config ) { + return nb::iter(nb::cast(config.keys())); + } ) + .def( "__len__", + []( eckit::Configuration const& config ) { + return config.keys().size(); + } ) + .def( "__repr__", []( eckit::Configuration const& config ) { + return "_atlas4py.eckit.Configuration("_s + nb::str( make_object( config ) ) + ")"_s; + } ); + + nb::class_( m, "eckit.LocalConfiguration" ) + .def( nb::init() ) + .def( "__setitem__", + []( eckit::LocalConfiguration& config, std::string const& key, nb::object value ) { + config_set(config,key,value); + } ) + .def( "__repr__", []( eckit::LocalConfiguration const& config ) { + return "_atlas4py.eckit.LocalConfiguration("_s + nb::str( make_object( config ) ) + ")"_s; + } ); + + nb::class_( m, "Config" ) + .def( nb::init() ) + .def_static( "from_kwargs", []( nb::kwargs kwargs ) { + return make_Config(kwargs); + } ) + .def_static( "from_yaml", []( std::string const& yaml ) { + return atlas::util::Config( eckit::YAMLConfiguration(yaml) ); + }, "yaml"_a ) + .def_static( "from_file", []( const nb::object path, std::string const& format ) { + // path accepts string but also path-like objects (e.g. pathlib.Path) + if ( !format.empty() ) { + auto format_lowercase = to_lowercase(format); + if (format_lowercase != "yaml" && format_lowercase != "json") { + throw std::runtime_error("Only 'yaml' or 'json' format is supported"); + } + } + return atlas::util::Config( eckit::PathName{nb::cast( nb::str(path) )} ); + }, "path"_a, "format"_a = "" ) + .def( "__repr__", []( atlas::util::Config const& config ) { + return "_atlas4py.Config("_s + nb::str( make_object( config ) ) + ")"_s; + } ); +} diff --git a/src/atlas4py/_atlas4py_Config.hpp b/src/atlas4py/_atlas4py_Config.hpp new file mode 100644 index 0000000..82f2d66 --- /dev/null +++ b/src/atlas4py/_atlas4py_Config.hpp @@ -0,0 +1,21 @@ +#pragma once + +#include + +#include "eckit/config/Configuration.h" +#include "atlas/util/Config.h" + +namespace nb = ::nanobind; + +namespace atlas4py { + + // Bindings for atlas::util::Config + void bind_Config( nb::module_& m ); + + // Convert eckit::Configuration to a corresponding Python object + nb::object make_object( eckit::Configuration const& v ); + + // Create an atlas::util::Config from Python keyword arguments + atlas::util::Config make_Config( nb::kwargs const& kwargs ); + +} // namespace atlas4py diff --git a/tests/test_bindings.py b/tests/test_bindings.py index 423bb48..0f89f5e 100644 --- a/tests/test_bindings.py +++ b/tests/test_bindings.py @@ -1,7 +1,7 @@ +import atlas4py import numpy as np import pytest -import atlas4py # -- Fixtures -- @@ -149,3 +149,112 @@ def test_gmsh_output(structured_mesh): # Clean up the output file os.remove(output_file) + +def test_config_set_python_types(): + from atlas4py import Config + + # Flat types + c = Config() + c["int"] = 42 + c["float"] = 3.14 + c["bool"] = True + c["str"] = "hello" + c["int_list"] = [1, 2, 3] + c["float_list"] = [1.1, 2.2, 3.3] + c["str_list"] = ["a", "b", "c"] + c["bool_list"] = [True, False, True] + + # Nested dict (mapping protocol) + c["nested"] = {"x": 1, "y": 2} + # List of dicts (sequence of mappings) + c["list_of_dicts"] = [{"a": 1}, {"b": 2}] + + # Retrieve and check + assert c["int"] == 42 + assert c["float"] == pytest.approx(3.14) + assert c["bool"] is True + assert c["str"] == "hello" + assert c["int_list"] == [1, 2, 3] + assert c["float_list"] == pytest.approx([1.1, 2.2, 3.3]) + assert c["str_list"] == ["a", "b", "c"] + assert c["bool_list"] == [True, False, True] + assert dict(c["nested"]) == {"x": 1, "y": 2} + assert [dict(d) for d in c["list_of_dicts"]] == [{"a": 1}, {"b": 2}] + +def test_config_contains(): + config = atlas4py.Config.from_kwargs(option1="value1", option2=42) + assert "option1" in config + assert "option2" in config + assert "option3" not in config + # dotted path lookup + config["outer.inner"] = 1 + assert "outer" in config + assert "outer.inner" in config + assert "outer.missing" not in config + + +def test_config_mapping_protocol(): + config = atlas4py.Config.from_kwargs(option1="value1", option2=42, option3=3.14) + # dict() uses keys() + __getitem__ + d = dict(config) + assert d == {"option1": "value1", "option2": 42, "option3": 3.14} + # __iter__ + assert list(config) == config.keys() + # __len__ + assert len(config) == 3 + + +def test_config_from_kwargs(): + config = atlas4py.Config.from_kwargs(option1="value1", option2=42) + config["option3"] = 3.14 + assert config["option1"] == "value1" + assert config["option2"] == 42 + assert config["option3"] == 3.14 + assert config.keys() == ["option1", "option2", "option3"] + +def test_config_from_yaml(): + yaml_string = """ +option1: value1 +option2: 42 +outer: + inner_option1: 3.14 + inner_option2: true +""" + config = atlas4py.Config.from_yaml(yaml_string) + config["outer.inner_option3"] = "added_value" + + assert config.keys() == ["option1", "option2", "outer"] + assert config["option1"] == "value1" + assert config["option2"] == 42 + assert config["outer.inner_option1"] == 3.14 + assert config["outer.inner_option2"] == True + outer = config["outer"] + assert outer["inner_option1"] == 3.14 + assert outer["inner_option2"] == True + assert outer["inner_option3"] == "added_value" + + +def test_config_from_file(tmp_path): + test_config_yaml = tmp_path / "test_config.yaml" + with open(test_config_yaml, "w") as f: + f.write( + """ +option1: value1 +option2: 42 +outer: + inner_option1: 3.14 + inner_option2: true +""" + ) + config = atlas4py.Config.from_file(test_config_yaml) + config["outer.inner_option3"] = "added_value" + + assert config.keys() == ["option1", "option2", "outer"] + assert config["option1"] == "value1" + assert config["option2"] == 42 + assert config["outer.inner_option1"] == 3.14 + assert config["outer.inner_option2"] == True + outer = config["outer"] + assert outer["inner_option1"] == 3.14 + assert outer["inner_option2"] == True + assert outer["inner_option3"] == "added_value"