mirror of https://github.com/google/benchmark.git
Switch bindings implementation to `nanobind` (#1526)
* End support for Python 3.7, update cibuildwheel and publish actions Removes Python 3.7 from the support matrix, since it does not support PEP590 vectorcalls. Bumps the `cibuildwheel` and `pypa-publish` actions to their latest available versions respectively. * Add nanobind to the Bazel dependencies, add a BUILD file The build file builds nanobind as a static `cc_library`. Currently, the git SHA points to HEAD, since some necessary features have not been included in a release yet. * Delete pybind11 BUILD file * Switch bindings implementation to nanobind Switches over the binding tool to `nanobind` from `pybind11`. Most changes in the build setup itself were drop-in replacements of existing code changed to nanobind names, no new concepts needed to be implemented. Sets the minimum required macOS to 10.14 for full C++17 support. Also, to avoid ambiguities in Bazel, build for macOS 11 on Mac ARM64. * Use Bazel select for linker options Guards against unknown linker option errors by selecting required linker options for nanobind only on macOS, where they are relevant. Other changes: * Bump cibuildwheel action to v2.12.0 * Bump Bazel for aarch64 linux wheels to 6.0.0 * Remove C++17 flag from build files since it is present in setup.py `bazel build` command * Bump nanobind commit to current HEAD (TBD: Bump to next stable release) * Unbreak Windows builds of nanobind-based bindings Guards compiler options behind a new `select` macro choosing between MSVC and not MSVC. Other changes: * Inject the proper C++17 standard cxxopt in the `setup.py` build command. * Bump nanobind to current HEAD. * Make `macos` a benchmark-wide condition, with public visibility to allow its use in the nanobind BUILD file. * Fall back to `nb::implicitly_convertible` for Counter construction Since `benchmark::Counter` only has a constructor for `double`, the nanobind `nb::init_implicit` template cannot be used. Therefore, to support implicit construction from ints, we fall back to the `nb::implicitly_convertible` template instead.
This commit is contained in:
parent
f59d021ebc
commit
80a3c5e4d9
|
@ -5,7 +5,7 @@ if ! bazel version; then
|
|||
fi
|
||||
echo "Installing wget and downloading $arch Bazel binary from GitHub releases."
|
||||
yum install -y wget
|
||||
wget "https://github.com/bazelbuild/bazel/releases/download/5.2.0/bazel-5.2.0-linux-$arch" -O /usr/local/bin/bazel
|
||||
wget "https://github.com/bazelbuild/bazel/releases/download/6.0.0/bazel-6.0.0-linux-$arch" -O /usr/local/bin/bazel
|
||||
chmod +x /usr/local/bin/bazel
|
||||
else
|
||||
# bazel is installed for the correct architecture
|
||||
|
|
|
@ -11,6 +11,7 @@ jobs:
|
|||
name: Test GBM Python bindings on ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ ubuntu-latest, macos-latest, windows-latest ]
|
||||
|
||||
|
|
|
@ -14,10 +14,10 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Install Python 3.9
|
||||
uses: actions/setup-python@v3
|
||||
- name: Install Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.9
|
||||
python-version: 3.11
|
||||
|
||||
- name: Build and check sdist
|
||||
run: |
|
||||
|
@ -46,11 +46,11 @@ jobs:
|
|||
platforms: all
|
||||
|
||||
- name: Build wheels on ${{ matrix.os }} using cibuildwheel
|
||||
uses: pypa/cibuildwheel@v2.9.0
|
||||
uses: pypa/cibuildwheel@v2.12.0
|
||||
env:
|
||||
CIBW_BUILD: 'cp37-* cp38-* cp39-* cp310-* cp311-*'
|
||||
CIBW_SKIP: "cp37-*-arm64 *-musllinux_*"
|
||||
# TODO: Build ppc64le using some other trick
|
||||
CIBW_BUILD: 'cp38-* cp39-* cp310-* cp311-*'
|
||||
CIBW_SKIP: "*-musllinux_*"
|
||||
CIBW_TEST_SKIP: "*-macosx_arm64"
|
||||
CIBW_ARCHS_LINUX: x86_64 aarch64
|
||||
CIBW_ARCHS_MACOS: x86_64 arm64
|
||||
CIBW_ARCHS_WINDOWS: AMD64
|
||||
|
@ -73,7 +73,7 @@ jobs:
|
|||
name: dist
|
||||
path: dist
|
||||
|
||||
- uses: pypa/gh-action-pypi-publish@v1.5.0
|
||||
- uses: pypa/gh-action-pypi-publish@v1.6.4
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.PYPI_PASSWORD }}
|
||||
|
|
|
@ -18,6 +18,12 @@ config_setting(
|
|||
visibility = [":__subpackages__"],
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "macos",
|
||||
constraint_values = ["@platforms//os:macos"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
config_setting(
|
||||
name = "perfcounters",
|
||||
define_values = {
|
||||
|
|
|
@ -44,13 +44,13 @@ def benchmark_deps():
|
|||
tag = "release-1.11.0",
|
||||
)
|
||||
|
||||
if "pybind11" not in native.existing_rules():
|
||||
http_archive(
|
||||
name = "pybind11",
|
||||
build_file = "@//bindings/python:pybind11.BUILD",
|
||||
sha256 = "eacf582fa8f696227988d08cfc46121770823839fe9e301a20fbce67e7cd70ec",
|
||||
strip_prefix = "pybind11-2.10.0",
|
||||
urls = ["https://github.com/pybind/pybind11/archive/v2.10.0.tar.gz"],
|
||||
if "nanobind" not in native.existing_rules():
|
||||
git_repository(
|
||||
name = "nanobind",
|
||||
remote = "https://github.com/wjakob/nanobind.git",
|
||||
commit = "fe3ecb800a7a3e8023e8ee77167a6241591e0b8b",
|
||||
build_file = "@//bindings/python:nanobind.BUILD",
|
||||
recursive_init_submodules = True,
|
||||
)
|
||||
|
||||
if "libpfm" not in native.existing_rules():
|
||||
|
|
|
@ -6,7 +6,6 @@ py_library(
|
|||
visibility = ["//visibility:public"],
|
||||
deps = [
|
||||
":_benchmark",
|
||||
# pip; absl:app
|
||||
],
|
||||
)
|
||||
|
||||
|
@ -17,10 +16,13 @@ py_extension(
|
|||
"-fexceptions",
|
||||
"-fno-strict-aliasing",
|
||||
],
|
||||
features = ["-use_header_modules"],
|
||||
features = [
|
||||
"-use_header_modules",
|
||||
"-parse_headers",
|
||||
],
|
||||
deps = [
|
||||
"//:benchmark",
|
||||
"@pybind11",
|
||||
"@nanobind",
|
||||
"@python_headers",
|
||||
],
|
||||
)
|
||||
|
|
|
@ -2,19 +2,16 @@
|
|||
|
||||
#include "benchmark/benchmark.h"
|
||||
|
||||
#include <map>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
#include "nanobind/nanobind.h"
|
||||
#include "nanobind/operators.h"
|
||||
#include "nanobind/stl/bind_map.h"
|
||||
#include "nanobind/stl/string.h"
|
||||
#include "nanobind/stl/vector.h"
|
||||
|
||||
#include "pybind11/operators.h"
|
||||
#include "pybind11/pybind11.h"
|
||||
#include "pybind11/stl.h"
|
||||
#include "pybind11/stl_bind.h"
|
||||
|
||||
PYBIND11_MAKE_OPAQUE(benchmark::UserCounters);
|
||||
NB_MAKE_OPAQUE(benchmark::UserCounters);
|
||||
|
||||
namespace {
|
||||
namespace py = ::pybind11;
|
||||
namespace nb = nanobind;
|
||||
|
||||
std::vector<std::string> Initialize(const std::vector<std::string>& argv) {
|
||||
// The `argv` pointers here become invalid when this function returns, but
|
||||
|
@ -38,14 +35,15 @@ std::vector<std::string> Initialize(const std::vector<std::string>& argv) {
|
|||
}
|
||||
|
||||
benchmark::internal::Benchmark* RegisterBenchmark(const char* name,
|
||||
py::function f) {
|
||||
nb::callable f) {
|
||||
return benchmark::RegisterBenchmark(
|
||||
name, [f](benchmark::State& state) { f(&state); });
|
||||
}
|
||||
|
||||
PYBIND11_MODULE(_benchmark, m) {
|
||||
NB_MODULE(_benchmark, m) {
|
||||
|
||||
using benchmark::TimeUnit;
|
||||
py::enum_<TimeUnit>(m, "TimeUnit")
|
||||
nb::enum_<TimeUnit>(m, "TimeUnit")
|
||||
.value("kNanosecond", TimeUnit::kNanosecond)
|
||||
.value("kMicrosecond", TimeUnit::kMicrosecond)
|
||||
.value("kMillisecond", TimeUnit::kMillisecond)
|
||||
|
@ -53,74 +51,74 @@ PYBIND11_MODULE(_benchmark, m) {
|
|||
.export_values();
|
||||
|
||||
using benchmark::BigO;
|
||||
py::enum_<BigO>(m, "BigO")
|
||||
nb::enum_<BigO>(m, "BigO")
|
||||
.value("oNone", BigO::oNone)
|
||||
.value("o1", BigO::o1)
|
||||
.value("oN", BigO::oN)
|
||||
.value("oNSquared", BigO::oNSquared)
|
||||
.value("oNCubed", BigO::oNCubed)
|
||||
.value("oLogN", BigO::oLogN)
|
||||
.value("oNLogN", BigO::oLogN)
|
||||
.value("oNLogN", BigO::oNLogN)
|
||||
.value("oAuto", BigO::oAuto)
|
||||
.value("oLambda", BigO::oLambda)
|
||||
.export_values();
|
||||
|
||||
using benchmark::internal::Benchmark;
|
||||
py::class_<Benchmark>(m, "Benchmark")
|
||||
// For methods returning a pointer tor the current object, reference
|
||||
// return policy is used to ask pybind not to take ownership oof the
|
||||
nb::class_<Benchmark>(m, "Benchmark")
|
||||
// For methods returning a pointer to the current object, reference
|
||||
// return policy is used to ask nanobind not to take ownership of the
|
||||
// returned object and avoid calling delete on it.
|
||||
// https://pybind11.readthedocs.io/en/stable/advanced/functions.html#return-value-policies
|
||||
//
|
||||
// For methods taking a const std::vector<...>&, a copy is created
|
||||
// because a it is bound to a Python list.
|
||||
// https://pybind11.readthedocs.io/en/stable/advanced/cast/stl.html
|
||||
.def("unit", &Benchmark::Unit, py::return_value_policy::reference)
|
||||
.def("arg", &Benchmark::Arg, py::return_value_policy::reference)
|
||||
.def("args", &Benchmark::Args, py::return_value_policy::reference)
|
||||
.def("range", &Benchmark::Range, py::return_value_policy::reference,
|
||||
py::arg("start"), py::arg("limit"))
|
||||
.def("unit", &Benchmark::Unit, nb::rv_policy::reference)
|
||||
.def("arg", &Benchmark::Arg, nb::rv_policy::reference)
|
||||
.def("args", &Benchmark::Args, nb::rv_policy::reference)
|
||||
.def("range", &Benchmark::Range, nb::rv_policy::reference,
|
||||
nb::arg("start"), nb::arg("limit"))
|
||||
.def("dense_range", &Benchmark::DenseRange,
|
||||
py::return_value_policy::reference, py::arg("start"),
|
||||
py::arg("limit"), py::arg("step") = 1)
|
||||
.def("ranges", &Benchmark::Ranges, py::return_value_policy::reference)
|
||||
nb::rv_policy::reference, nb::arg("start"),
|
||||
nb::arg("limit"), nb::arg("step") = 1)
|
||||
.def("ranges", &Benchmark::Ranges, nb::rv_policy::reference)
|
||||
.def("args_product", &Benchmark::ArgsProduct,
|
||||
py::return_value_policy::reference)
|
||||
.def("arg_name", &Benchmark::ArgName, py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("arg_name", &Benchmark::ArgName, nb::rv_policy::reference)
|
||||
.def("arg_names", &Benchmark::ArgNames,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("range_pair", &Benchmark::RangePair,
|
||||
py::return_value_policy::reference, py::arg("lo1"), py::arg("hi1"),
|
||||
py::arg("lo2"), py::arg("hi2"))
|
||||
nb::rv_policy::reference, nb::arg("lo1"), nb::arg("hi1"),
|
||||
nb::arg("lo2"), nb::arg("hi2"))
|
||||
.def("range_multiplier", &Benchmark::RangeMultiplier,
|
||||
py::return_value_policy::reference)
|
||||
.def("min_time", &Benchmark::MinTime, py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("min_time", &Benchmark::MinTime, nb::rv_policy::reference)
|
||||
.def("min_warmup_time", &Benchmark::MinWarmUpTime,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("iterations", &Benchmark::Iterations,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("repetitions", &Benchmark::Repetitions,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("report_aggregates_only", &Benchmark::ReportAggregatesOnly,
|
||||
py::return_value_policy::reference, py::arg("value") = true)
|
||||
nb::rv_policy::reference, nb::arg("value") = true)
|
||||
.def("display_aggregates_only", &Benchmark::DisplayAggregatesOnly,
|
||||
py::return_value_policy::reference, py::arg("value") = true)
|
||||
nb::rv_policy::reference, nb::arg("value") = true)
|
||||
.def("measure_process_cpu_time", &Benchmark::MeasureProcessCPUTime,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("use_real_time", &Benchmark::UseRealTime,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def("use_manual_time", &Benchmark::UseManualTime,
|
||||
py::return_value_policy::reference)
|
||||
nb::rv_policy::reference)
|
||||
.def(
|
||||
"complexity",
|
||||
(Benchmark * (Benchmark::*)(benchmark::BigO)) & Benchmark::Complexity,
|
||||
py::return_value_policy::reference,
|
||||
py::arg("complexity") = benchmark::oAuto);
|
||||
nb::rv_policy::reference,
|
||||
nb::arg("complexity") = benchmark::oAuto);
|
||||
|
||||
using benchmark::Counter;
|
||||
py::class_<Counter> py_counter(m, "Counter");
|
||||
nb::class_<Counter> py_counter(m, "Counter");
|
||||
|
||||
py::enum_<Counter::Flags>(py_counter, "Flags")
|
||||
nb::enum_<Counter::Flags>(py_counter, "Flags")
|
||||
.value("kDefaults", Counter::Flags::kDefaults)
|
||||
.value("kIsRate", Counter::Flags::kIsRate)
|
||||
.value("kAvgThreads", Counter::Flags::kAvgThreads)
|
||||
|
@ -132,28 +130,29 @@ PYBIND11_MODULE(_benchmark, m) {
|
|||
.value("kAvgIterationsRate", Counter::Flags::kAvgIterationsRate)
|
||||
.value("kInvert", Counter::Flags::kInvert)
|
||||
.export_values()
|
||||
.def(py::self | py::self);
|
||||
.def(nb::self | nb::self);
|
||||
|
||||
py::enum_<Counter::OneK>(py_counter, "OneK")
|
||||
nb::enum_<Counter::OneK>(py_counter, "OneK")
|
||||
.value("kIs1000", Counter::OneK::kIs1000)
|
||||
.value("kIs1024", Counter::OneK::kIs1024)
|
||||
.export_values();
|
||||
|
||||
py_counter
|
||||
.def(py::init<double, Counter::Flags, Counter::OneK>(),
|
||||
py::arg("value") = 0., py::arg("flags") = Counter::kDefaults,
|
||||
py::arg("k") = Counter::kIs1000)
|
||||
.def(py::init([](double value) { return Counter(value); }))
|
||||
.def(nb::init<double, Counter::Flags, Counter::OneK>(),
|
||||
nb::arg("value") = 0., nb::arg("flags") = Counter::kDefaults,
|
||||
nb::arg("k") = Counter::kIs1000)
|
||||
.def("__init__", ([](Counter *c, double value) { new (c) Counter(value); }))
|
||||
.def_readwrite("value", &Counter::value)
|
||||
.def_readwrite("flags", &Counter::flags)
|
||||
.def_readwrite("oneK", &Counter::oneK);
|
||||
py::implicitly_convertible<py::float_, Counter>();
|
||||
py::implicitly_convertible<py::int_, Counter>();
|
||||
.def_readwrite("oneK", &Counter::oneK)
|
||||
.def(nb::init_implicit<double>());
|
||||
|
||||
py::bind_map<benchmark::UserCounters>(m, "UserCounters");
|
||||
nb::implicitly_convertible<nb::int_, Counter>();
|
||||
|
||||
nb::bind_map<benchmark::UserCounters>(m, "UserCounters");
|
||||
|
||||
using benchmark::State;
|
||||
py::class_<State>(m, "State")
|
||||
nb::class_<State>(m, "State")
|
||||
.def("__bool__", &State::KeepRunning)
|
||||
.def_property_readonly("keep_running", &State::KeepRunning)
|
||||
.def("pause_timing", &State::PauseTiming)
|
||||
|
@ -168,15 +167,16 @@ PYBIND11_MODULE(_benchmark, m) {
|
|||
.def_property("items_processed", &State::items_processed,
|
||||
&State::SetItemsProcessed)
|
||||
.def("set_label", (void (State::*)(const char*)) & State::SetLabel)
|
||||
.def("range", &State::range, py::arg("pos") = 0)
|
||||
.def("range", &State::range, nb::arg("pos") = 0)
|
||||
.def_property_readonly("iterations", &State::iterations)
|
||||
.def_property_readonly("name", &State::name)
|
||||
.def_readwrite("counters", &State::counters)
|
||||
.def_property_readonly("thread_index", &State::thread_index)
|
||||
.def_property_readonly("threads", &State::threads);
|
||||
|
||||
m.def("Initialize", Initialize);
|
||||
m.def("RegisterBenchmark", RegisterBenchmark,
|
||||
py::return_value_policy::reference);
|
||||
nb::rv_policy::reference);
|
||||
m.def("RunSpecifiedBenchmarks",
|
||||
[]() { benchmark::RunSpecifiedBenchmarks(); });
|
||||
m.def("ClearRegisteredBenchmarks", benchmark::ClearRegisteredBenchmarks);
|
||||
|
|
|
@ -0,0 +1,52 @@
|
|||
|
||||
config_setting(
|
||||
name = "msvc_compiler",
|
||||
flag_values = {"@bazel_tools//tools/cpp:compiler": "msvc-cl"},
|
||||
)
|
||||
|
||||
cc_library(
|
||||
name = "nanobind",
|
||||
hdrs = glob(
|
||||
include = [
|
||||
"include/nanobind/*.h",
|
||||
"include/nanobind/stl/*.h",
|
||||
"include/nanobind/detail/*.h",
|
||||
],
|
||||
exclude = [],
|
||||
),
|
||||
srcs = [
|
||||
"include/nanobind/stl/detail/nb_dict.h",
|
||||
"include/nanobind/stl/detail/nb_list.h",
|
||||
"include/nanobind/stl/detail/traits.h",
|
||||
"ext/robin_map/include/tsl/robin_map.h",
|
||||
"ext/robin_map/include/tsl/robin_hash.h",
|
||||
"ext/robin_map/include/tsl/robin_growth_policy.h",
|
||||
"ext/robin_map/include/tsl/robin_set.h",
|
||||
"src/buffer.h",
|
||||
"src/common.cpp",
|
||||
"src/error.cpp",
|
||||
"src/implicit.cpp",
|
||||
"src/nb_enum.cpp",
|
||||
"src/nb_func.cpp",
|
||||
"src/nb_internals.cpp",
|
||||
"src/nb_internals.h",
|
||||
"src/nb_type.cpp",
|
||||
"src/tensor.cpp",
|
||||
"src/trampoline.cpp",
|
||||
],
|
||||
copts = select({
|
||||
":msvc_compiler": [],
|
||||
"//conditions:default": [
|
||||
"-fexceptions",
|
||||
"-Os", # size optimization
|
||||
"-flto", # enable LTO
|
||||
],
|
||||
}),
|
||||
linkopts = select({
|
||||
"@com_github_google_benchmark//:macos": ["-undefined suppress", "-flat_namespace"],
|
||||
"//conditions:default": [],
|
||||
}),
|
||||
includes = ["include", "ext/robin_map/include"],
|
||||
deps = ["@python_headers"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
|
@ -1,20 +0,0 @@
|
|||
cc_library(
|
||||
name = "pybind11",
|
||||
hdrs = glob(
|
||||
include = [
|
||||
"include/pybind11/*.h",
|
||||
"include/pybind11/detail/*.h",
|
||||
],
|
||||
exclude = [
|
||||
"include/pybind11/common.h",
|
||||
"include/pybind11/eigen.h",
|
||||
],
|
||||
),
|
||||
copts = [
|
||||
"-fexceptions",
|
||||
"-Wno-undefined-inline",
|
||||
"-Wno-pragma-once-outside-header",
|
||||
],
|
||||
includes = ["include"],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
25
setup.py
25
setup.py
|
@ -88,23 +88,31 @@ class BuildBazelExtension(build_ext.build_ext):
|
|||
bazel_argv = [
|
||||
"bazel",
|
||||
"build",
|
||||
str(ext.bazel_target),
|
||||
ext.bazel_target,
|
||||
f"--symlink_prefix={temp_path / 'bazel-'}",
|
||||
f"--compilation_mode={'dbg' if self.debug else 'opt'}",
|
||||
# C++17 is required by nanobind
|
||||
f"--cxxopt={'/std:c++17' if IS_WINDOWS else '-std=c++17'}",
|
||||
]
|
||||
|
||||
if IS_WINDOWS:
|
||||
# Link with python*.lib.
|
||||
for library_dir in self.library_dirs:
|
||||
bazel_argv.append("--linkopt=/LIBPATH:" + library_dir)
|
||||
elif IS_MAC and platform.machine() == "x86_64":
|
||||
bazel_argv.append("--macos_minimum_os=10.9")
|
||||
elif IS_MAC:
|
||||
if platform.machine() == "x86_64":
|
||||
# C++17 needs macOS 10.14 at minimum
|
||||
bazel_argv.append("--macos_minimum_os=10.14")
|
||||
|
||||
# ARCHFLAGS is always set by cibuildwheel before macOS wheel builds.
|
||||
archflags = os.getenv("ARCHFLAGS", "")
|
||||
if "arm64" in archflags:
|
||||
bazel_argv.append("--cpu=darwin_arm64")
|
||||
bazel_argv.append("--macos_cpus=arm64")
|
||||
# cross-compilation for Mac ARM64 on GitHub Mac x86 runners.
|
||||
# ARCHFLAGS is set by cibuildwheel before macOS wheel builds.
|
||||
archflags = os.getenv("ARCHFLAGS", "")
|
||||
if "arm64" in archflags:
|
||||
bazel_argv.append("--cpu=darwin_arm64")
|
||||
bazel_argv.append("--macos_cpus=arm64")
|
||||
|
||||
elif platform.machine() == "arm64":
|
||||
bazel_argv.append("--macos_minimum_os=11.0")
|
||||
|
||||
self.spawn(bazel_argv)
|
||||
|
||||
|
@ -146,7 +154,6 @@ setuptools.setup(
|
|||
"Intended Audience :: Developers",
|
||||
"Intended Audience :: Science/Research",
|
||||
"License :: OSI Approved :: Apache Software License",
|
||||
"Programming Language :: Python :: 3.7",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
|
|
Loading…
Reference in New Issue