Compare commits
31
Commits
003db65f9c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44adb10989 | ||
|
|
65e825f8db | ||
|
|
8eb24a0b71 | ||
|
|
0c21166e53 | ||
|
|
bad72120f9 | ||
|
|
0141c461da | ||
|
|
eb6453362d | ||
|
|
d5c99be4d8 | ||
|
|
d769af9efe | ||
|
|
f9333c72be | ||
|
|
bf0837e4a6 | ||
|
|
0b4ecccb71 | ||
|
|
ccf19ac0ab | ||
|
|
7527d3763f | ||
|
|
dbec1b4fca | ||
|
|
520bfc2054 | ||
|
|
816e3a59d0 | ||
|
|
a72a685c84 | ||
|
|
c28f08b75d | ||
|
|
ae3f10a640 | ||
|
|
2c5df3fd63 | ||
|
|
8eed2dc7f0 | ||
|
|
26ce893920 | ||
|
|
10a33f8921 | ||
|
|
6f1c5312e0 | ||
|
|
52bd89ad6e | ||
|
|
8c0775d784 | ||
|
|
02526784a2 | ||
|
|
3261a4cd4b | ||
|
|
8098216a83 | ||
|
|
8e92ceeabd |
+18
@@ -0,0 +1,18 @@
|
|||||||
|
# Developer Notes
|
||||||
|
|
||||||
|
## Bump Version Up
|
||||||
|
|
||||||
|
### Bump OMRF Version Up
|
||||||
|
|
||||||
|
- Change the version declared in `Cargo.toml`.
|
||||||
|
|
||||||
|
### Bump OMRF Packer Version Up
|
||||||
|
|
||||||
|
- Change the version declared in `pyproject.toml`.
|
||||||
|
- Change the version constant `VERSION` declared in `utils.py`.
|
||||||
|
- Change the minimum required version declared in the metadata example section of `README.md`.
|
||||||
|
|
||||||
|
## Version Tag
|
||||||
|
|
||||||
|
- Use `omrf/x.y.z` to tag the version of `sarasacw-omrf`.
|
||||||
|
- Use `omrf-packer/x.y.z` to tag the version of `sarasacw-omrf-packer`.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Allowed Data Types
|
||||||
|
|
||||||
|
Under this section we discuss which data types are allowed to appear in an FFI
|
||||||
|
interface and how to convert Rust data types into these allowed types. The
|
||||||
|
correspondence between these types and their C/C++ counterparts is covered in
|
||||||
|
[C/C++ Headers](c-cpp-headers.md).
|
||||||
|
|
||||||
|
## Primitive Type Conversion
|
||||||
|
|
||||||
|
This subsection covers the conversion of primitive types. The rules described here
|
||||||
|
apply to both parameter and return value positions.
|
||||||
|
|
||||||
|
### Types Allowed to Cross the Boundary Directly
|
||||||
|
|
||||||
|
The following Rust types are allowed to be passed directly across the FFI boundary:
|
||||||
|
|
||||||
|
- `bool`
|
||||||
|
- `f32`
|
||||||
|
- `f64`
|
||||||
|
- `i8`
|
||||||
|
- `i16`
|
||||||
|
- `i32`
|
||||||
|
- `i64`
|
||||||
|
- `u8`
|
||||||
|
- `u16`
|
||||||
|
- `u32`
|
||||||
|
- `u64`
|
||||||
|
- `usize`
|
||||||
|
- `isize`
|
||||||
|
|
||||||
|
### The u128 and i128 Problem
|
||||||
|
|
||||||
|
The `u128` and `i128` types have very poor support in the C/C++ standard libraries.
|
||||||
|
Therefore they cannot be passed directly across the FFI boundary.
|
||||||
|
|
||||||
|
To transfer data of these types, refer to the later section about passing opaque
|
||||||
|
structs.
|
||||||
|
|
||||||
|
### The char Problem
|
||||||
|
|
||||||
|
The Rust `char` type only holds valid Unicode scalar values. The Rust official
|
||||||
|
documentation explicitly states that the `char` type does not have FFI safety, so it
|
||||||
|
cannot be used as a value passed across the FFI boundary.
|
||||||
|
|
||||||
|
The solution is to use the Rust `u32` type for passing. Under this scheme:
|
||||||
|
|
||||||
|
- The function signature uses `u32` as the parameter type.
|
||||||
|
- For input parameters, use `char::try_from` to perform a fallible conversion that
|
||||||
|
validates the incoming character.
|
||||||
|
- For output parameters, use `u32::from` for the conversion.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# C/C++ Headers
|
||||||
|
|
||||||
|
Under this section we discuss how to write the C/C++ header files that accompany the
|
||||||
|
Rust FFI library. The Rust-side counterpart of each topic is covered in
|
||||||
|
[FFI Function Signatures](ffi-function-signatures.md) and
|
||||||
|
[Allowed Data Types](allowed-data-types.md).
|
||||||
|
|
||||||
|
## Function Declaration Style
|
||||||
|
|
||||||
|
FFI functions must be exported under C names, that is, they must not go through the
|
||||||
|
C++ name mangling mechanism. In the generated C header file, you need to use the
|
||||||
|
`extern "C"` specifier, the `__cplusplus` macro and macro conditionals to import the
|
||||||
|
declarations correctly, for example:
|
||||||
|
|
||||||
|
```c++
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif // __cplusplus
|
||||||
|
|
||||||
|
CError WFStartup(void);
|
||||||
|
|
||||||
|
// More FFI functions omitted...
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
} // extern "C"
|
||||||
|
#endif // __cplusplus
|
||||||
|
```
|
||||||
|
|
||||||
|
## Parameter Declarations
|
||||||
|
|
||||||
|
A declaration of an exported FFI function in the C header file generally looks like
|
||||||
|
the following:
|
||||||
|
|
||||||
|
```c++
|
||||||
|
CError FBAdd(
|
||||||
|
OMRF_IN_PARAM_TY(uint32_t, in_a),
|
||||||
|
OMRF_IN_PARAM_TY(uint32_t, in_b),
|
||||||
|
OMRF_OUT_PARAM_TY(uint32_t, out_rv),
|
||||||
|
OMRF_OUT_PARAM_TY(bool, out_overflow));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Required Header Files
|
||||||
|
|
||||||
|
The generated C/C++ header files must include the appropriate header files for the
|
||||||
|
types used in the declarations.
|
||||||
|
|
||||||
|
- In C, the `bool` type is provided by `<stddef.h>`. C++ supports `bool` natively
|
||||||
|
and does not need any header file.
|
||||||
|
- In C, the integer types are provided by `<stdint.h>`. In C++, they are provided
|
||||||
|
by `<cstdint>`.
|
||||||
|
- In C, the `uintptr_t` and `intptr_t` types are provided by `<stdint.h>`. In C++,
|
||||||
|
they are provided by `<cstdint>`.
|
||||||
|
- The floating-point types do not require any header file.
|
||||||
|
|
||||||
|
## Type Correspondences
|
||||||
|
|
||||||
|
The following table shows the correspondence between the Rust types that can cross
|
||||||
|
the FFI boundary directly (listed in [Allowed Data Types](allowed-data-types.md))
|
||||||
|
and their C/C++ counterparts. The left column lists the Rust type and the right
|
||||||
|
column lists the corresponding C/C++ type.
|
||||||
|
|
||||||
|
| Rust | C/C++ |
|
||||||
|
|------|-------|
|
||||||
|
| `bool` | `bool` |
|
||||||
|
| `f32` | `float` |
|
||||||
|
| `f64` | `double` |
|
||||||
|
| `i8` | `int8_t` |
|
||||||
|
| `i16` | `int16_t` |
|
||||||
|
| `i32` | `int32_t` |
|
||||||
|
| `i64` | `int64_t` |
|
||||||
|
| `u8` | `uint8_t` |
|
||||||
|
| `u16` | `uint16_t` |
|
||||||
|
| `u32` | `uint32_t` |
|
||||||
|
| `u64` | `uint64_t` |
|
||||||
|
| `usize` | `uintptr_t` |
|
||||||
|
| `isize` | `intptr_t` |
|
||||||
|
|
||||||
|
## The char Problem
|
||||||
|
|
||||||
|
For the Rust `char` type, the C/C++ header side uses `char32_t` as the parameter
|
||||||
|
type. Note that `char32_t` can hold any 32-bit unsigned integer, which is broader
|
||||||
|
than the set of valid Unicode scalar values carried by the Rust `char` type. The
|
||||||
|
Rust side is responsible for validating the incoming value; see
|
||||||
|
[The char Problem](allowed-data-types.md#the-char-problem) in
|
||||||
|
[Allowed Data Types](allowed-data-types.md).
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
# FFI Function Signatures
|
||||||
|
|
||||||
|
Under this section we discuss the signature requirements of the exported FFI
|
||||||
|
functions.
|
||||||
|
|
||||||
|
## C-style Name Export
|
||||||
|
|
||||||
|
FFI functions must be exported under C names, that is, they must not go through the
|
||||||
|
C++ name mangling mechanism.
|
||||||
|
|
||||||
|
In Rust, you need to decorate the function with the `#[unsafe(no_mangle)]` attribute
|
||||||
|
together with `extern "C"`, for example:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn WFStartup() -> CError {
|
||||||
|
// Function body omitted...
|
||||||
|
}
|
||||||
|
|
||||||
|
// More FFI functions omitted...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Export Name Style
|
||||||
|
|
||||||
|
In this subsection, we discuss the naming style of exported functions.
|
||||||
|
|
||||||
|
In this design, there are exactly two styles to choose from. We call them the
|
||||||
|
Windows style and the POSIX style. Either style can be chosen freely, but within a
|
||||||
|
single library the two styles must not be mixed.
|
||||||
|
|
||||||
|
We recommend the following selection rule: if your library is Windows-only, choose
|
||||||
|
the Windows style; otherwise, choose the POSIX style.
|
||||||
|
|
||||||
|
### Windows Style
|
||||||
|
|
||||||
|
The Windows style format is `<PREFIX><FUNC>` for ordinary functions, and
|
||||||
|
`<PREFIX><STRUCT><FUNC>` for opaque struct functions.
|
||||||
|
|
||||||
|
In the format, `<STRUCT>` is the name of the opaque struct and `<FUNC>` is the name
|
||||||
|
of the function. Both of them must use PascalCase naming.
|
||||||
|
|
||||||
|
For `<PREFIX>`, it is a prefix identifier that is specific to this library. As we
|
||||||
|
all know, C has no namespace concept, and all functions are exported into the same
|
||||||
|
space. Therefore, avoiding name collisions between functions is an important task.
|
||||||
|
By prepending `<PREFIX>` to the function name, name duplication can be avoided as
|
||||||
|
much as possible. For this reason, you need to choose `<PREFIX>` carefully and try
|
||||||
|
to pick a prefix that is unlikely to collide.
|
||||||
|
|
||||||
|
In the Windows style, there are two spellings for `<PREFIX>`. One is the
|
||||||
|
all-uppercase style and the other is the style in which only the first letter is
|
||||||
|
uppercase and all remaining letters are lowercase. Choose according to your own
|
||||||
|
needs. In addition, the length of `<PREFIX>` must not exceed 5 characters, and
|
||||||
|
it must not contain anything other than letters and digits (that is, underscores are
|
||||||
|
not allowed).
|
||||||
|
|
||||||
|
The following examples show some legal and illegal Windows style function names:
|
||||||
|
|
||||||
|
| Name | Verdict | Reason |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `WFStartup` | Legal | An ordinary (non opaque struct) function. |
|
||||||
|
| `WFIconGetHandle` | Legal | An opaque struct function whose `<STRUCT>` is `Icon`. |
|
||||||
|
| `WFWordHandleGetHandle` | Legal | An opaque struct function whose `<STRUCT>` is `WordHandle`. |
|
||||||
|
| `FlGetMessage` | Legal | Uses a `<PREFIX>`, which is `Fl`, that capitalizes only its first letter. |
|
||||||
|
| `MyRustGetMessage` | Illegal | `<PREFIX>`, which is `MyRust`, does not keep all remaining letters lowercase. |
|
||||||
|
| `My_RustGetMessage` | Illegal | `<PREFIX>` contains an underscore. |
|
||||||
|
| `IconGetHandle` | Illegal | Missing `<PREFIX>`. |
|
||||||
|
| `QwertyuiopGetData` | Illegal | `<PREFIX>`, which is `Qwertyuiop`, is too long. |
|
||||||
|
| `WFget_message` | Illegal | `<FUNC>`, which is `get_message`, is not PascalCase. |
|
||||||
|
| `WFicon_handleGetMessage` | Illegal | `<STRUCT>`, which is `icon_handle`, is not PascalCase. |
|
||||||
|
|
||||||
|
### POSIX Style
|
||||||
|
|
||||||
|
The POSIX style format is `<PREFIX>_<FUNC>` for ordinary functions, and
|
||||||
|
`<PREFIX>_<STRUCT>_<FUNC>` for opaque struct functions.
|
||||||
|
|
||||||
|
The `<PREFIX>`, `<STRUCT>` and `<FUNC>` components in the format have the same
|
||||||
|
meaning as in the Windows style, but the format is different. `<STRUCT>` and `<FUNC>`
|
||||||
|
must use snake_case naming. `<PREFIX>` must be all lowercase, and it must not contain
|
||||||
|
anything other than letters and digits (that is, underscores are not allowed). Its
|
||||||
|
length and selection requirements are the same as in the Windows style.
|
||||||
|
|
||||||
|
The following examples show some legal and illegal POSIX style function names:
|
||||||
|
|
||||||
|
| Name | Verdict | Reason |
|
||||||
|
|------|---------|--------|
|
||||||
|
| `wf_startup` | Legal | An ordinary (non opaque struct) function. |
|
||||||
|
| `wf_icon_get_handle` | Legal | An opaque struct function whose `<STRUCT>` is `icon`. |
|
||||||
|
| `wf_word_handle_get_handle` | Legal | An opaque struct function whose `<STRUCT>` is `word_handle`. |
|
||||||
|
| `Fl_get_message` | Illegal | `<PREFIX>`, which is `Fl`, is not all lowercase. |
|
||||||
|
| `my_rust_get_message` | Illegal | `<PREFIX>`, which is `my_rust`, contains an underscore. |
|
||||||
|
| `icon_get_handle` | Illegal | Missing `<PREFIX>`. |
|
||||||
|
| `qwertyuiop_get_data` | Illegal | `<PREFIX>`, which is `qwertyuiop`, is too long. |
|
||||||
|
| `wf_GetMessage` | Illegal | `<FUNC>`, which is `GetMessage`, is not snake_case. |
|
||||||
|
| `wf_Icon_get_message` | Illegal | `<STRUCT>`, which is `Icon`, is not snake_case. |
|
||||||
|
|
||||||
|
## Parameters and Return Value
|
||||||
|
|
||||||
|
Under this section we discuss the parameters and return value of the exported
|
||||||
|
functions. A standard FFI function looks like the following on the Rust side:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[unsafe(no_mangle)]
|
||||||
|
pub extern "C" fn FBAdd(
|
||||||
|
in_a: in_param_ty!(u32),
|
||||||
|
in_b: in_param_ty!(u32),
|
||||||
|
out_rv: out_param_ty!(u32),
|
||||||
|
out_overflow: out_param_ty!(bool),
|
||||||
|
) -> CError {
|
||||||
|
// Function body omitted...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
As shown in the example, this function has four parameters. The first two are input
|
||||||
|
parameters and the last two are output parameters. The return value of the function
|
||||||
|
is `CError`.
|
||||||
|
|
||||||
|
### Parameter Rules
|
||||||
|
|
||||||
|
To follow the best practice of C for the signature of functions with any number of
|
||||||
|
input parameters and output parameters, we must design the function signature this
|
||||||
|
way. To implement such a function that accepts any number of inputs and outputs, we
|
||||||
|
need to put both the input parameters and the output parameters into the parameter
|
||||||
|
list. All input parameters must precede any output parameter. The return value,
|
||||||
|
`CError`, is used only to indicate whether the function succeeded or failed.
|
||||||
|
|
||||||
|
The naming style of the function parameters (both input and output) must be
|
||||||
|
snake_case and must not start with an underscore.
|
||||||
|
|
||||||
|
#### Input Parameters
|
||||||
|
|
||||||
|
Input parameters are passed directly by value (for example, integers, floats or
|
||||||
|
pointers). In the example, `in_a` and `in_b` are both of type `u32`. You can use
|
||||||
|
the `in_param_ty!` macro provided by the crate to conveniently mark this.
|
||||||
|
|
||||||
|
By convention, input parameters are prefixed with `in_`, but this is not enforced.
|
||||||
|
|
||||||
|
#### Output Parameters
|
||||||
|
|
||||||
|
Output parameters are passed as pointers. In the example, `out_rv` is actually of
|
||||||
|
type `*mut u32` and `out_overflow` is actually of type `*mut bool`. To avoid
|
||||||
|
repeatedly writing these pointer types and to avoid writing them incorrectly, you
|
||||||
|
can use the `out_param_ty!` macro provided by the crate, which is symmetric with
|
||||||
|
`in_param_ty!`.
|
||||||
|
|
||||||
|
At the same time, you can use the `deref_out_param!` macro provided by the crate to
|
||||||
|
conveniently dereference an output parameter for assignment, or directly use the
|
||||||
|
`set_out_param!` macro provided by the crate for assignment. But you usually do not
|
||||||
|
need to do that, because in later chapters you will be guided on how to correctly
|
||||||
|
use `cffi_wrapper!` to maximize the efficiency of writing FFI functions.
|
||||||
|
|
||||||
|
By convention, output parameters are prefixed with `out_`, but this is not enforced.
|
||||||
|
|
||||||
|
### The Return Value
|
||||||
|
|
||||||
|
The return value of an FFI function must be of the `CError` type.
|
||||||
|
|
||||||
|
`CError` is defined in `last_error::CError` as a plain `u32`. It is not an enum with
|
||||||
|
a fixed set of members. Instead, `CERROR_OK` is the single value that represents
|
||||||
|
absolute, unambiguous success. Every other value's meaning is decided by the
|
||||||
|
consuming project: it may denote a failure, or, like some Win32 functions, a
|
||||||
|
partial success.
|
||||||
|
|
||||||
|
#### Migrating from a bool Return Value
|
||||||
|
|
||||||
|
Projects that previously returned a plain `bool` can adopt the error-code scheme
|
||||||
|
with minimal change: define exactly one non-success code, for example
|
||||||
|
`const CERROR_FAIL: CError = 1;`, and map every error type to it. The result is
|
||||||
|
equivalent to a `bool`: `CERROR_OK` for success and `CERROR_FAIL` for any failure,
|
||||||
|
while leaving room to introduce finer-grained codes later.
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# C-friendly FFI Design
|
||||||
|
|
||||||
|
This document is a guide for authoring Rust libraries that expose a C-friendly FFI
|
||||||
|
and distributing them as ordinary CMake/pkg-config packages. It covers how to design
|
||||||
|
C-friendly FFI signatures, how to pass primitive types, enums, strings and
|
||||||
|
Rust-specific constructs such as `Option` and `Result` across the boundary, and how
|
||||||
|
to write the accompanying C/C++ header files.
|
||||||
|
|
||||||
|
## Contents
|
||||||
|
|
||||||
|
- [FFI Function Signatures](ffi-function-signatures.md)
|
||||||
|
- [Allowed Data Types](allowed-data-types.md)
|
||||||
|
- [C/C++ Headers](c-cpp-headers.md)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "sarasacw-omrf"
|
name = "sarasacw-omrf"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
authors = ["yyc12345 <yyc12321@outlook.com>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "The facilities for Rust, specifically designed for the SarasaCW OMRF project, to support C-friendly FFI."
|
description = "The facilities for Rust, specifically designed for the SarasaCW OMRF project, to support C-friendly FFI."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod cffi;
|
pub mod cffi;
|
||||||
pub mod cstr_ffi;
|
pub mod cstr_ffi;
|
||||||
pub mod last_error;
|
pub mod last_error;
|
||||||
|
pub mod library_lifecycle;
|
||||||
pub mod object_pool;
|
pub mod object_pool;
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Reference-counted startup/shutdown lifecycle for a DLL-style FFI library, in the spirit of
|
||||||
|
//! COM's `CoInitialize` / `CoUninitialize`.
|
||||||
|
//!
|
||||||
|
//! [`LibraryLifecycle`] holds an internal reference count together with a piece of state `S`:
|
||||||
|
//!
|
||||||
|
//! - on the `0 -> 1` transition ([`startup`](LibraryLifecycle::startup)) the supplied init closure
|
||||||
|
//! runs and its produced `S` is stored;
|
||||||
|
//! - on the `-> 0` transition ([`shutdown`](LibraryLifecycle::shutdown)) the stored `S` is taken
|
||||||
|
//! out and handed to the supplied destroy closure.
|
||||||
|
//!
|
||||||
|
//! Repeated calls are allowed as long as they are paired. The count and the state are guarded by a
|
||||||
|
//! [`RwLock`]; [`with_state`](LibraryLifecycle::with_state) takes a read lock (read access proceeds
|
||||||
|
//! concurrently) while `startup` / `shutdown` take a write lock.
|
||||||
|
//!
|
||||||
|
//! # Typical usage
|
||||||
|
//!
|
||||||
|
//! A single instance is usually stored in a `static LazyLock` and shared across all FFI entry
|
||||||
|
//! points. The state `S` typically aggregates the library's DLL-level resources -- for example a
|
||||||
|
//! number of [`ObjectPool`](crate::object_pool::ObjectPool)s and other globals that must be
|
||||||
|
//! constructed on first startup and torn down on last shutdown.
|
||||||
|
//!
|
||||||
|
//! # Call order
|
||||||
|
//!
|
||||||
|
//! Every `shutdown` must match a preceding successful `startup`. Calling `shutdown` when the count
|
||||||
|
//! is already zero is an error.
|
||||||
|
//!
|
||||||
|
//! # Closures
|
||||||
|
//!
|
||||||
|
//! Init and destroy closures are supplied **per call** (not at construction). Only the closure
|
||||||
|
//! passed to the transition call (`0 -> 1` for init, `-> 0` for destroy) is actually run; on any
|
||||||
|
//! other call the passed closure is dropped without being run, so callers should pass the same
|
||||||
|
//! init/destroy each time.
|
||||||
|
//!
|
||||||
|
//! The init closure may fail; its error is type-erased. The destroy closure must never fail
|
||||||
|
//! (destructor semantics) -- if its cleanup can fail it must be handled inside the closure.
|
||||||
|
//!
|
||||||
|
//! # Re-entrancy
|
||||||
|
//!
|
||||||
|
//! Closures run while the internal lock is held and **must not re-enter** the same
|
||||||
|
//! [`LibraryLifecycle`] (the lock is not re-entrant and would deadlock).
|
||||||
|
|
||||||
|
use std::sync::RwLock;
|
||||||
|
use thiserror::Error as TeError;
|
||||||
|
|
||||||
|
struct Inner<S> {
|
||||||
|
count: usize,
|
||||||
|
state: Option<S>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reference-counted startup/shutdown lifecycle holding a piece of state `S`.
|
||||||
|
///
|
||||||
|
/// See the module-level documentation for the overall contract, call order, and typical usage.
|
||||||
|
pub struct LibraryLifecycle<S> {
|
||||||
|
inner: RwLock<Inner<S>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Error returned by [`LibraryLifecycle`] operations.
|
||||||
|
#[derive(Debug, TeError)]
|
||||||
|
pub enum Error {
|
||||||
|
/// The initialization closure failed; the original error is type-erased.
|
||||||
|
#[error("initialization failed: {0}")]
|
||||||
|
Init(#[source] Box<dyn std::error::Error + Send + Sync + 'static>),
|
||||||
|
/// `shutdown` was called when the reference count was already zero (unbalanced call).
|
||||||
|
#[error("shutdown called when reference count is already zero")]
|
||||||
|
Underflow,
|
||||||
|
/// The state was accessed while the lifecycle is not active (before `startup` or after the
|
||||||
|
/// matching `shutdown`).
|
||||||
|
#[error("state accessed while not active")]
|
||||||
|
NotActive,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<S> LibraryLifecycle<S> {
|
||||||
|
/// Create a new lifecycle with reference count zero and no state.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
inner: RwLock::new(Inner {
|
||||||
|
count: 0,
|
||||||
|
state: None,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Increment the reference count.
|
||||||
|
///
|
||||||
|
/// On the `0 -> 1` transition the supplied `init` closure runs and its produced `S` is stored;
|
||||||
|
/// the call returns `Ok(true)`. If `init` fails, neither the count nor the state is changed and
|
||||||
|
/// an error is returned.
|
||||||
|
///
|
||||||
|
/// On any other call (count already `> 0`) the supplied `init` is **dropped without being run**,
|
||||||
|
/// the count is simply incremented, and the call returns `Ok(false)`.
|
||||||
|
///
|
||||||
|
/// The init closure runs under the write lock and must not re-enter this lifecycle. Its error
|
||||||
|
/// type `E` is type-erased.
|
||||||
|
pub fn startup<E>(&self, init: impl FnOnce() -> Result<S, E>) -> Result<bool, Error>
|
||||||
|
where
|
||||||
|
E: std::error::Error + Send + Sync + 'static,
|
||||||
|
{
|
||||||
|
let mut inner = self.inner.write().expect("unexpected poison lock");
|
||||||
|
if inner.count == 0 {
|
||||||
|
let s = init().map_err(|e| Error::Init(Box::new(e)))?;
|
||||||
|
inner.state = Some(s);
|
||||||
|
inner.count = 1;
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
inner.count += 1;
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrement the reference count.
|
||||||
|
///
|
||||||
|
/// On the `-> 0` transition the stored `S` is taken out and handed to the supplied `destroy`
|
||||||
|
/// closure, which must never fail (destructor semantics), and the call returns `Ok(true)`. On
|
||||||
|
/// any other call (count stays `> 0`) the supplied `destroy` is **dropped without being run**,
|
||||||
|
/// the count is simply decremented, and the call returns `Ok(false)`.
|
||||||
|
///
|
||||||
|
/// Returns an error if the count was already zero.
|
||||||
|
///
|
||||||
|
/// The destroy closure runs under the write lock and must not re-enter this lifecycle.
|
||||||
|
pub fn shutdown(&self, destroy: impl FnOnce(S)) -> Result<bool, Error> {
|
||||||
|
let mut inner = self.inner.write().expect("unexpected poison lock");
|
||||||
|
if inner.count == 0 {
|
||||||
|
return Err(Error::Underflow);
|
||||||
|
}
|
||||||
|
inner.count -= 1;
|
||||||
|
if inner.count == 0 {
|
||||||
|
// Invariant: state is Some whenever count > 0.
|
||||||
|
let s = inner.state.take().expect("state present while count > 0");
|
||||||
|
destroy(s);
|
||||||
|
Ok(true)
|
||||||
|
} else {
|
||||||
|
Ok(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Access the stored state by shared reference under a read lock.
|
||||||
|
///
|
||||||
|
/// Multiple `with_state` calls can run concurrently. Returns an error if the lifecycle is not
|
||||||
|
/// currently active (count is zero).
|
||||||
|
///
|
||||||
|
/// `S` is immutable from the outside; if mutation is needed, build it into `S` via interior
|
||||||
|
/// mutability (e.g. `Mutex`, atomics). The closure must not re-enter this lifecycle.
|
||||||
|
pub fn with_state<R>(&self, f: impl FnOnce(&S) -> R) -> Result<R, Error> {
|
||||||
|
let inner = self.inner.read().expect("unexpected poison lock");
|
||||||
|
match &inner.state {
|
||||||
|
Some(s) => Ok(f(s)),
|
||||||
|
None => Err(Error::NotActive),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Current reference count.
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.inner.read().expect("unexpected poison lock").count
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
//! Integration tests for the `library_lifecycle` module.
|
||||||
|
|
||||||
|
use sarasacw_omrf::library_lifecycle::LibraryLifecycle;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
use thiserror::Error as TeError;
|
||||||
|
|
||||||
|
#[derive(Debug, TeError)]
|
||||||
|
enum TestErr {
|
||||||
|
#[error("boom")]
|
||||||
|
Boom,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct State {
|
||||||
|
value: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: a single paired startup/shutdown runs init once and destroy once, destroy receives the
|
||||||
|
/// state produced by init, both report that they ran the closure, and the count returns to zero.
|
||||||
|
#[test]
|
||||||
|
fn paired_startup_shutdown() {
|
||||||
|
let inits = AtomicUsize::new(0);
|
||||||
|
let destroys = AtomicUsize::new(0);
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
|
||||||
|
let ran_init = lc
|
||||||
|
.startup(|| -> Result<State, TestErr> {
|
||||||
|
inits.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(State { value: 42 })
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(ran_init);
|
||||||
|
|
||||||
|
let ran_destroy = lc
|
||||||
|
.shutdown(|s| {
|
||||||
|
destroys.fetch_add(1, Ordering::SeqCst);
|
||||||
|
assert_eq!(s.value, 42);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(ran_destroy);
|
||||||
|
|
||||||
|
assert_eq!(inits.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(destroys.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(lc.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: multiple startups followed by matching shutdowns run init and destroy only on the
|
||||||
|
/// boundary transitions (once each); the init/destroy closures passed on non-transition calls are
|
||||||
|
/// dropped without being run, and the bool return reflects exactly which calls ran a closure.
|
||||||
|
#[test]
|
||||||
|
fn multiple_startups_shutdowns_run_only_on_boundaries() {
|
||||||
|
let inits = AtomicUsize::new(0);
|
||||||
|
let destroys = AtomicUsize::new(0);
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
|
||||||
|
let startup_ran: Vec<bool> = (0..3)
|
||||||
|
.map(|_| {
|
||||||
|
lc.startup(|| -> Result<State, TestErr> {
|
||||||
|
inits.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(State { value: 7 })
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(startup_ran, vec![true, false, false]);
|
||||||
|
assert_eq!(lc.count(), 3);
|
||||||
|
assert_eq!(inits.load(Ordering::SeqCst), 1);
|
||||||
|
|
||||||
|
let shutdown_ran: Vec<bool> = (0..3)
|
||||||
|
.map(|_| {
|
||||||
|
lc.shutdown(|_s| {
|
||||||
|
destroys.fetch_add(1, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(shutdown_ran, vec![false, false, true]);
|
||||||
|
|
||||||
|
assert_eq!(inits.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(destroys.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(lc.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: after a full cycle, a new cycle re-runs init and destroy (re-initialization works).
|
||||||
|
#[test]
|
||||||
|
fn reinitialization_runs_init_and_destroy_again() {
|
||||||
|
let inits = AtomicUsize::new(0);
|
||||||
|
let destroys = AtomicUsize::new(0);
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
|
||||||
|
for _ in 0..2 {
|
||||||
|
let ran_init = lc
|
||||||
|
.startup(|| -> Result<State, TestErr> {
|
||||||
|
inits.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(State { value: 1 })
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(ran_init);
|
||||||
|
let ran_destroy = lc
|
||||||
|
.shutdown(|_s| {
|
||||||
|
destroys.fetch_add(1, Ordering::SeqCst);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
assert!(ran_destroy);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(inits.load(Ordering::SeqCst), 2);
|
||||||
|
assert_eq!(destroys.load(Ordering::SeqCst), 2);
|
||||||
|
assert_eq!(lc.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: `shutdown` with the count already at zero is an error (unbalanced call).
|
||||||
|
#[test]
|
||||||
|
fn shutdown_when_idle_is_error() {
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
assert!(lc.shutdown(|_| ()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: `with_state` before any `startup` (and after the matching `shutdown`) is an error
|
||||||
|
/// because there is no active state.
|
||||||
|
#[test]
|
||||||
|
fn with_state_when_not_active_is_error() {
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
assert!(lc.with_state(|_s| ()).is_err());
|
||||||
|
|
||||||
|
lc.startup(|| -> Result<State, TestErr> { Ok(State { value: 5 }) })
|
||||||
|
.unwrap();
|
||||||
|
lc.shutdown(|_| ()).unwrap();
|
||||||
|
assert!(lc.with_state(|_s| ()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: a failing init closure leaves the count at zero and the state absent, and the error is
|
||||||
|
/// propagated (type-erased into `Error::Init`).
|
||||||
|
#[test]
|
||||||
|
fn init_failure_keeps_count_zero_and_propagates_error() {
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
let r = lc.startup(|| -> Result<State, TestErr> { Err(TestErr::Boom) });
|
||||||
|
assert!(r.is_err());
|
||||||
|
assert_eq!(lc.count(), 0);
|
||||||
|
// State is absent, so accessing it errors.
|
||||||
|
assert!(lc.with_state(|_s| ()).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Coverage: `with_state` reads the stored state during an active lifecycle.
|
||||||
|
#[test]
|
||||||
|
fn with_state_reads_active_state() {
|
||||||
|
let lc: LibraryLifecycle<State> = LibraryLifecycle::new();
|
||||||
|
lc.startup(|| -> Result<State, TestErr> { Ok(State { value: 99 }) })
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let observed = lc.with_state(|s| s.value).unwrap();
|
||||||
|
assert_eq!(observed, 99);
|
||||||
|
|
||||||
|
lc.shutdown(|_| ()).unwrap();
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# sarasacw-omrf-packer
|
||||||
|
|
||||||
|
`sarasacw-omrf-packer` is the distribution packer of the Sarasas Chip Workshop Oh My Rust FFI (OMRF) toolset.
|
||||||
|
Given a Rust project that exposes a C-friendly FFI as a `cdylib`, it assembles a CMake-style redistributable tree.
|
||||||
|
It involves header files, the built dynamic library, and generated CMake and pkg-config package files,
|
||||||
|
and it can additionally bundle the result into a zip archive.
|
||||||
|
|
||||||
|
It targets Rust FFI projects that need to be distributed and consumed like ordinary CMake/pkg-config packages,
|
||||||
|
including on MSVC-only Windows toolchains where pkg-config alone is not enough.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Barely works for the specific workflow of Sarasas Chip Workshop.
|
||||||
|
And will be improved by new requirement coming from Sarasas Chip Workshop devlopment.
|
||||||
|
|
||||||
|
Projects declare the minimum required packer version through the `min_version` field in their `[package.metadata.omrf]` table;
|
||||||
|
running an older packer than requested fails fast with a clear error.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the MIT License.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Install from PyPI (the project is built with `uv`, but `pipx install` or `pip install` work just as well on the published wheel):
|
||||||
|
|
||||||
|
```console
|
||||||
|
uv tool install sarasacw-omrf-packer
|
||||||
|
```
|
||||||
|
|
||||||
|
The packer does not build the project itself. Build it first -- `cargo build --release`, adding `--target <triple>` for cross-compilation -- then run:
|
||||||
|
|
||||||
|
```console
|
||||||
|
sarasacw-omrf-packer -m path/to/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo` and `rustc` are resolved from `PATH`; override them with the environment variables listed in the [Environment Variables](#environment-variables) section.
|
||||||
|
|
||||||
|
### Command-line options
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-m`, `--manifest <CARGO.TOML>` | yes | Path to the `Cargo.toml` of the project to pack. |
|
||||||
|
| `-d`, `--dist-dir <DIR>` | no | Directory where the redistributable tree is materialized (`bin/`, `include/`, `lib/`, ...). |
|
||||||
|
| `-z`, `--dist-zip <ZIP>` | no | Path of the zip archive produced from the redistributable tree. |
|
||||||
|
| `-t`, `--target <TRIPLE>` | no | Target triple to pack for (e.g. `x86_64-pc-windows-msvc`). Defaults to the host toolchain triple. |
|
||||||
|
|
||||||
|
`--dist-dir` and `--dist-zip` are independent: pass either, both, or neither. Omitting both performs a dry run that validates the configuration without writing any output. Run `sarasacw-omrf-packer --help` for the authoritative list.
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
All packer used properties are stored in target Rust manifest file as metadata style.
|
||||||
|
There is an example about how to define them in `Cargo.toml`.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[package.metadata.omrf]
|
||||||
|
# Minimum required version of sarasacw-omrf-packer.
|
||||||
|
# Running with an older version fails with an error.
|
||||||
|
# This property is optional; when omitted, no version constraint is enforced.
|
||||||
|
min_version = "1.0.0"
|
||||||
|
|
||||||
|
# Header files to distribute. Each entry installs one file (or, with glob
|
||||||
|
# expansion, a set of files) into the `include` directory of the installation.
|
||||||
|
# Each entry carries a `from` source path and a `to` destination path.
|
||||||
|
# This property is required. If you really want to distribute nothing header files,
|
||||||
|
# leave empty list here.
|
||||||
|
headers = [
|
||||||
|
# `from` is a path relative to the directory that contains this `Cargo.toml`.
|
||||||
|
# `to` is a path relative to the `include` directory (a subdirectory of the install directory).
|
||||||
|
# An empty `to` places the file directly under the `include` directory.
|
||||||
|
{ from = "cbindgen/my_crate.h", to = "" },
|
||||||
|
{ from = "cbindgen/our_crate.h", to = "SomePrefix" },
|
||||||
|
# `from` also supports UNIX-style glob (pathname) expansion.
|
||||||
|
# The matcher uses the leading literal portion of the pattern (`pattern/with`
|
||||||
|
# here) as the root directory and copies every matched file into the `to`
|
||||||
|
# directory, preserving the directory hierarchy beneath that root.
|
||||||
|
{ from = "pattern/with/**/*", to = "" },
|
||||||
|
# The same pattern, but nested under an `AllInOne` prefix.
|
||||||
|
{ from = "pattern/with/**/*", to = "AllInOne" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.omrf.cmake]
|
||||||
|
# Namespace part of the generated CMake target.
|
||||||
|
# This property is optional; it defaults to the name of the target Rust project.
|
||||||
|
namespace_name = "foobar"
|
||||||
|
# Target-name part of the generated CMake target.
|
||||||
|
# This property is optional; it defaults to the name of the target Rust project.
|
||||||
|
target_name = "foobar"
|
||||||
|
# Declared CMake dependencies.
|
||||||
|
# Each entry carries a `package` (passed to find_dependency argument) and a `target`
|
||||||
|
# (linked into the generated imported target via INTERFACE_LINK_LIBRARIES).
|
||||||
|
# This property is optional; it defaults to no dependencies.
|
||||||
|
# Because the packer only ever ships dynamic libraries, there are no private or
|
||||||
|
# static dependencies, and no separate field for them.
|
||||||
|
# Declaring dependencies here is rarely necessary and is NOT recommended: it
|
||||||
|
# behaves like a CMake interface/public dependency, and exposing other
|
||||||
|
# libraries' raw types across the FFI boundary raises ABI-compatibility
|
||||||
|
# concerns across runtimes. Use it only when this crate is explicitly a wrapper
|
||||||
|
# around another library.
|
||||||
|
dependencies = [
|
||||||
|
{ package = "ZLIB 1.3.2 REQUIRED", target = "ZLIB::ZLIB" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata.omrf.pkgconfig]
|
||||||
|
# The unique identifier of the package.
|
||||||
|
# This property is optional; it defaults to the name of the target Rust project.
|
||||||
|
id = "foobar"
|
||||||
|
# Human-readable name of the package.
|
||||||
|
# This property is optional; it defaults to the name of the target Rust project.
|
||||||
|
name = "Foo Bar"
|
||||||
|
# Brief description of the package.
|
||||||
|
# This property is optional; it defaults to the description of the target Rust project.
|
||||||
|
description = "a brown fox jumps over a lazy dog."
|
||||||
|
# Declared public pkg-config dependencies.
|
||||||
|
# This property is optional; it defaults to no dependencies.
|
||||||
|
# Because the packer only ever ships dynamic libraries, there are no private
|
||||||
|
# dependencies and Requires.private is not used.
|
||||||
|
# Declaring dependencies here is rarely necessary and is NOT recommended, for
|
||||||
|
# the same ABI-compatibility reasons as the CMake dependencies above. Use it
|
||||||
|
# only when this crate is explicitly a wrapper around another library.
|
||||||
|
requires = ["libfoo >= 1.0", "libbar"]
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
- `OMRF_PACKER_CARGO`: Path to the `cargo` executable used to collect project metadata.
|
||||||
|
When unset, the packer invokes `cargo` as resolved from `PATH`.
|
||||||
|
- `OMRF_PACKER_RUSTC`: Path to the `rustc` executable used to resolve the host toolchain triple.
|
||||||
|
When unset, the packer invokes `rustc` as resolved from `PATH`.
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
def main():
|
|
||||||
print("Hello from sarasacw-omrf-packer!")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
+32
-2
@@ -1,9 +1,39 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "sarasacw-omrf-packer"
|
name = "sarasacw-omrf-packer"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
description = "Add your description here"
|
description = "The packer for Rust, specifically designed for the SarasaCW OMRF project, to distribute C-friendly FFI dynamic library as CMake package."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
authors = [
|
||||||
|
{ name = "yyc12345", email = "yyc12321@outlook.com" }
|
||||||
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
keywords = ["rust", "ffi", "cmake", "pkg-config", "packaging", "omrf"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Topic :: Software Development :: Build Tools",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: Microsoft :: Windows",
|
||||||
|
"Operating System :: POSIX",
|
||||||
|
"Operating System :: MacOS",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
|
"semver>=3.0.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
sarasacw-omrf-packer = "sarasacw_omrf_packer:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/SarasasChipWorkshop/sarasacw-omrf"
|
||||||
|
Repository = "https://github.com/SarasasChipWorkshop/sarasacw-omrf"
|
||||||
|
Issues = "https://github.com/SarasasChipWorkshop/sarasacw-omrf/issues"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["uv_build>=0.8.0,<0.9"]
|
||||||
|
build-backend = "uv_build"
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Command-line entry point of sarasacw-omrf-packer.
|
||||||
|
|
||||||
|
Wires the pipeline together: parse CLI options, build a
|
||||||
|
:class:`MetadataExtractor` wrapped in an :class:`ArtifactContext`, then drive
|
||||||
|
an :class:`Archiver` to copy headers/libraries and emit the CMake/pkg-config
|
||||||
|
files.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from .cli import Cli, parse as parse_cli
|
||||||
|
from .artifact import (
|
||||||
|
ArtifactContext,
|
||||||
|
resolve_include_copy,
|
||||||
|
resolve_lib_copy,
|
||||||
|
resolve_cmake_copy,
|
||||||
|
resolve_pkgconfig_copy,
|
||||||
|
)
|
||||||
|
from .archiver import Archiver
|
||||||
|
from .metadata import MetadataExtractor
|
||||||
|
|
||||||
|
|
||||||
|
class App:
|
||||||
|
"""High-level orchestrator of a single packaging run.
|
||||||
|
|
||||||
|
Holds the shared :class:`MetadataExtractor`/:class:`ArtifactContext` built
|
||||||
|
from the CLI options and exposes :meth:`run` to produce the distribution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__opts: Cli
|
||||||
|
__extractor: MetadataExtractor
|
||||||
|
__ctx: ArtifactContext
|
||||||
|
|
||||||
|
def __init__(self, opts: Cli) -> None:
|
||||||
|
"""Build the extractor and artifact context from CLI options.
|
||||||
|
|
||||||
|
:param opts: Parsed command-line options.
|
||||||
|
"""
|
||||||
|
# assign cli options
|
||||||
|
self.__opts = opts
|
||||||
|
# build essential instances
|
||||||
|
self.__extractor = MetadataExtractor(self.__opts.manifest)
|
||||||
|
self.__ctx = ArtifactContext(self.__opts, self.__extractor)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Produce the distribution.
|
||||||
|
|
||||||
|
Opens an :class:`Archiver` over the requested sinks, creates the basic
|
||||||
|
``bin/``/``include/``/``lib/`` layout, then copies headers, libraries
|
||||||
|
and generated package-manager files into it.
|
||||||
|
"""
|
||||||
|
# create distribution
|
||||||
|
with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver:
|
||||||
|
# create basic directory
|
||||||
|
archiver.push_dir(Path("bin"))
|
||||||
|
archiver.push_dir(Path("include"))
|
||||||
|
archiver.push_dir(Path("lib"))
|
||||||
|
# copy header files
|
||||||
|
logging.info("Copying header files...")
|
||||||
|
for info in resolve_include_copy(self.__ctx):
|
||||||
|
logging.info("Copying %s -> %s", info.from_path, info.to_path)
|
||||||
|
archiver.push_file(info.from_path, info.to_path)
|
||||||
|
# copy lib files
|
||||||
|
logging.info("Copying library files...")
|
||||||
|
for info in resolve_lib_copy(self.__ctx):
|
||||||
|
logging.info("Copying %s -> %s", info.from_path, info.to_path)
|
||||||
|
archiver.push_file(info.from_path, info.to_path)
|
||||||
|
# copy package distribution files
|
||||||
|
logging.info("Creating package manager files...")
|
||||||
|
for info in resolve_cmake_copy(self.__ctx):
|
||||||
|
logging.info("Creating %s", info.to_path)
|
||||||
|
archiver.push_text(info.text, info.to_path)
|
||||||
|
for info in resolve_pkgconfig_copy(self.__ctx):
|
||||||
|
logging.info("Creating %s", info.to_path)
|
||||||
|
archiver.push_text(info.text, info.to_path)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Entry point: parse arguments, configure logging and run :class:`App`.
|
||||||
|
|
||||||
|
Logs and exits with status ``1`` on initialization or runtime errors.
|
||||||
|
"""
|
||||||
|
# parse command line arguments
|
||||||
|
opts = parse_cli()
|
||||||
|
# setup logging
|
||||||
|
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
|
||||||
|
|
||||||
|
# initialize packer and run
|
||||||
|
try:
|
||||||
|
app = App(opts)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("fail to initialize packer: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
|
app.run()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("packer runtime error: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
"""Dual-sink archiver that materializes a distribution tree.
|
||||||
|
|
||||||
|
The :class:`Archiver` mirrors the same logical entries into an on-disk
|
||||||
|
directory and/or a zip archive, and is usable as a context manager.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
from types import TracebackType
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class Archiver:
|
||||||
|
"""Dual sink that mirrors the same distribution tree into a directory and a zip archive.
|
||||||
|
|
||||||
|
On construction the archiver (optionally) prepares a directory on disk and
|
||||||
|
(optionally) opens a :class:`zipfile.ZipFile` for writing. Every subsequent
|
||||||
|
:meth:`push_file`/:meth:`push_text` call then writes the same logical entry
|
||||||
|
-- identified by its ``arcname``, a path relative to the distribution root
|
||||||
|
-- into both destinations, skipping whichever sink was not requested.
|
||||||
|
|
||||||
|
The archiver is usable as a context manager so that the underlying archive
|
||||||
|
is closed deterministically::
|
||||||
|
|
||||||
|
with Archiver(dist_dir, dist_zip) as arc:
|
||||||
|
arc.push_file(...)
|
||||||
|
"""
|
||||||
|
|
||||||
|
__dist_dir: Optional[Path]
|
||||||
|
__dist_zip: Optional[zipfile.ZipFile]
|
||||||
|
|
||||||
|
def __init__(self, dist_dir: Optional[Path], dist_zip: Optional[Path]) -> None:
|
||||||
|
"""Configure the sinks and acquire their backing resources.
|
||||||
|
|
||||||
|
:param dist_dir: Directory to mirror the tree into. Created (with
|
||||||
|
parents) when given; ``None`` disables the directory sink.
|
||||||
|
:param dist_zip: Path of the zip archive to create, truncated on open.
|
||||||
|
``None`` disables the zip sink.
|
||||||
|
"""
|
||||||
|
# make sure the distribution directory is existing
|
||||||
|
self.__dist_dir = dist_dir
|
||||||
|
if self.__dist_dir is not None:
|
||||||
|
self.__dist_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
# create zip instance
|
||||||
|
if dist_zip is not None:
|
||||||
|
self.__dist_zip = zipfile.ZipFile(dist_zip, "w", zipfile.ZIP_DEFLATED)
|
||||||
|
else:
|
||||||
|
self.__dist_zip = None
|
||||||
|
|
||||||
|
def push_dir(self, arcname: Path) -> None:
|
||||||
|
"""Create an empty directory entry in both sinks.
|
||||||
|
|
||||||
|
:param arcname: Directory path relative to the distribution root.
|
||||||
|
Parent directories are created as needed for the directory sink.
|
||||||
|
"""
|
||||||
|
if self.__dist_dir is not None:
|
||||||
|
target_filepath = self.__dist_dir / arcname
|
||||||
|
target_filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
if self.__dist_zip is not None:
|
||||||
|
self.__dist_zip.mkdir(arcname.as_posix())
|
||||||
|
|
||||||
|
def push_file(self, filepath: Path, arcname: Path) -> None:
|
||||||
|
"""Mirror an existing file into both sinks.
|
||||||
|
|
||||||
|
:param filepath: Source file on disk to copy / embed.
|
||||||
|
:param arcname: Destination path relative to the distribution root.
|
||||||
|
Missing parent directories are created for the directory sink.
|
||||||
|
"""
|
||||||
|
if self.__dist_dir is not None:
|
||||||
|
target_filepath = self.__dist_dir / arcname
|
||||||
|
target_filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.copy(filepath, target_filepath)
|
||||||
|
|
||||||
|
if self.__dist_zip is not None:
|
||||||
|
self.__dist_zip.write(filepath, arcname.as_posix())
|
||||||
|
|
||||||
|
def push_text(self, text: str, arcname: Path) -> None:
|
||||||
|
"""Mirror in-memory text into both sinks.
|
||||||
|
|
||||||
|
:param text: UTF-8 text content to write.
|
||||||
|
:param arcname: Destination path relative to the distribution root.
|
||||||
|
Missing parent directories are created for the directory sink.
|
||||||
|
"""
|
||||||
|
if self.__dist_dir is not None:
|
||||||
|
target_filepath = self.__dist_dir / arcname
|
||||||
|
target_filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(target_filepath, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
|
||||||
|
if self.__dist_zip is not None:
|
||||||
|
self.__dist_zip.writestr(arcname.as_posix(), text)
|
||||||
|
|
||||||
|
def __enter__(self) -> "Archiver":
|
||||||
|
"""Enter the context and return this archiver as the bound target."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(
|
||||||
|
self,
|
||||||
|
exc_type: Optional[type[BaseException]],
|
||||||
|
exc_value: Optional[BaseException],
|
||||||
|
traceback: Optional[TracebackType],
|
||||||
|
) -> None:
|
||||||
|
"""Release the zip sink by closing its backing archive, if any.
|
||||||
|
|
||||||
|
Exceptions raised inside the ``with`` body are never suppressed.
|
||||||
|
"""
|
||||||
|
if self.__dist_zip is not None:
|
||||||
|
self.__dist_zip.close()
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
"""Resolution of the files and generated texts that make up a distribution.
|
||||||
|
|
||||||
|
Given an :class:`ArtifactContext`, the ``resolve_*_copy`` generators yield the
|
||||||
|
header copies, library copies and generated CMake/pkg-config texts that the
|
||||||
|
archiver then materializes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Iterator
|
||||||
|
from pathlib import Path
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from re import Pattern, compile
|
||||||
|
from .cli import Cli
|
||||||
|
from .utils import Triple
|
||||||
|
from .metadata import MetadataExtractor, Metadata
|
||||||
|
from .renders.cmake import CMakeDependency, CMakeProperties, CMakeRender
|
||||||
|
from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender
|
||||||
|
|
||||||
|
|
||||||
|
class ArtifactContext:
|
||||||
|
"""A wrapper of metadata with proper fallback"""
|
||||||
|
|
||||||
|
__opts: Cli
|
||||||
|
__extractor: MetadataExtractor
|
||||||
|
__metadata: Metadata
|
||||||
|
|
||||||
|
def __init__(self, opts: Cli, extractor: MetadataExtractor) -> None:
|
||||||
|
"""Wrap an extractor and eagerly fetch its :class:`Metadata`.
|
||||||
|
|
||||||
|
:param extractor: The extractor to wrap and read from.
|
||||||
|
"""
|
||||||
|
self.__opts = opts
|
||||||
|
self.__extractor = extractor
|
||||||
|
self.__metadata = self.__extractor.get_metadata()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def options(self) -> Cli:
|
||||||
|
"""The wrapped :class:`Cli` holding all command line arguments"""
|
||||||
|
return self.__opts
|
||||||
|
|
||||||
|
@property
|
||||||
|
def extractor(self) -> MetadataExtractor:
|
||||||
|
"""The wrapped :class:`MetadataExtractor`."""
|
||||||
|
return self.__extractor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def metadata(self) -> Metadata:
|
||||||
|
"""The cached :class:`Metadata` read from the extractor."""
|
||||||
|
return self.__metadata
|
||||||
|
|
||||||
|
|
||||||
|
_GLOB_MAGIC_PATTERN: Pattern = compile(r"[*?[]")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_glob_pattern(pattern: str) -> bool:
|
||||||
|
"""Check whether given string is a valid UNIX-style glob (pathname) pattern
|
||||||
|
|
||||||
|
:returns: True if it is, otherwise false.
|
||||||
|
"""
|
||||||
|
# TODO: only magic-character presence is checked; bracket expressions such
|
||||||
|
# as ``[abc]`` are not validated for balance. Acceptable today since users
|
||||||
|
# mostly rely on ``*``/``**``, but ``[]`` patterns may break this later.
|
||||||
|
return _GLOB_MAGIC_PATTERN.search(pattern) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_common_prefix(pattern: str) -> tuple[str, str]:
|
||||||
|
"""Extract the immutable common prefix of given UNIX-style glob (pathname) pattern.
|
||||||
|
|
||||||
|
For example, give ``some/path/**/*.h``, this function return ``some/path``
|
||||||
|
:returns: A tuple with 2 items.
|
||||||
|
First item is the immutable common prefix of given UNIX-style glob (pathname) pattern,
|
||||||
|
and the last item is the residue.
|
||||||
|
"""
|
||||||
|
# TODO: per-component wildcard detection reuses ``_is_glob_pattern``, which
|
||||||
|
# does not validate bracket expressions (see the TODO there). Same caveat.
|
||||||
|
parts = Path(pattern).parts
|
||||||
|
i = 0
|
||||||
|
while i < len(parts) and not _is_glob_pattern(parts[i]):
|
||||||
|
i += 1
|
||||||
|
return "/".join(parts[:i]), "/".join(parts[i:])
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class FileCopyInfo:
|
||||||
|
"""A single on-disk file to copy into the distribution."""
|
||||||
|
|
||||||
|
from_path: Path
|
||||||
|
"""The absolute path pointing to source file without any wildcard"""
|
||||||
|
to_path: Path
|
||||||
|
"""The path of destination file relative to the install directory"""
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
|
||||||
|
"""Yield :class:`FileCopyInfo` for every header declared in the metadata.
|
||||||
|
|
||||||
|
Literal ``from`` paths produce a single entry; glob ``from`` paths are
|
||||||
|
expanded relative to their immutable leading prefix.
|
||||||
|
"""
|
||||||
|
extractor = ctx.extractor
|
||||||
|
metadata = ctx.metadata
|
||||||
|
project_root = Path(extractor.get_project_dir())
|
||||||
|
|
||||||
|
for header in metadata.headers:
|
||||||
|
logging.debug(
|
||||||
|
"Resolving header copy rule: %s -> %s", header.from_path, header.to_path
|
||||||
|
)
|
||||||
|
to_path = Path("include") / Path(header.to_path)
|
||||||
|
if _is_glob_pattern(header.from_path):
|
||||||
|
(common_prefix, glob_residue) = _extract_common_prefix(header.from_path)
|
||||||
|
new_project_root = project_root / Path(common_prefix)
|
||||||
|
|
||||||
|
for subpath in new_project_root.glob(glob_residue):
|
||||||
|
# YYC MARK:
|
||||||
|
# Built ``subpath`` is prefix with ``new_project_root``
|
||||||
|
relative_subpath = subpath.relative_to(new_project_root)
|
||||||
|
yield FileCopyInfo(subpath, to_path / relative_subpath)
|
||||||
|
else:
|
||||||
|
yield FileCopyInfo(
|
||||||
|
project_root / header.from_path, to_path / header.from_path
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_WINDOWS_ENV_IDENTS: tuple[str, ...] = ("windows", "cygwin")
|
||||||
|
|
||||||
|
_LINUX_ENV_IDENTS: tuple[str, ...] = (
|
||||||
|
"linux",
|
||||||
|
"android",
|
||||||
|
"freebsd",
|
||||||
|
"openbsd",
|
||||||
|
"netbsd",
|
||||||
|
"haiku",
|
||||||
|
"hurd",
|
||||||
|
)
|
||||||
|
|
||||||
|
_MACOS_ENV_IDENTS: tuple[str, ...] = ("macos", "ios", "tvos", "visionos")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows_env(triple: Triple) -> bool:
|
||||||
|
return triple.operating_system in _WINDOWS_ENV_IDENTS
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_dll_artifact_name(name: str, triple: Triple) -> str:
|
||||||
|
"""Return the platform-appropriate dynamic-loadable file name for ``name``."""
|
||||||
|
match triple.operating_system:
|
||||||
|
case x if x in _WINDOWS_ENV_IDENTS:
|
||||||
|
return f"{name}.dll"
|
||||||
|
case x if x in _LINUX_ENV_IDENTS:
|
||||||
|
return f"lib{name}.so"
|
||||||
|
case x if x in _MACOS_ENV_IDENTS:
|
||||||
|
return f"lib{name}.dylib"
|
||||||
|
case _:
|
||||||
|
raise RuntimeError("not supported system")
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_lib_artifact_name(name: str, _: Triple) -> str:
|
||||||
|
"""Return the Windows import-library file name for ``name``.
|
||||||
|
|
||||||
|
Only meaningful on Windows; returned regardless of platform for use by the
|
||||||
|
CMake properties.
|
||||||
|
"""
|
||||||
|
# only Windows has this feature, so we simply return it
|
||||||
|
return f"{name}.dll.lib"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
|
||||||
|
"""Yield :class:`FileCopyInfo` for the built dynamic library (and, on Windows, the import library)."""
|
||||||
|
extractor = ctx.extractor
|
||||||
|
target_name = extractor.get_target_name()
|
||||||
|
|
||||||
|
# get triple info
|
||||||
|
target_triple = extractor.get_host_triple()
|
||||||
|
if ctx.options.target is not None:
|
||||||
|
target_triple = ctx.options.target
|
||||||
|
|
||||||
|
# compute target directory
|
||||||
|
target_directory = extractor.get_target_directory()
|
||||||
|
if ctx.options.target is not None:
|
||||||
|
target_directory /= str(ctx.options.target)
|
||||||
|
target_directory /= "release"
|
||||||
|
|
||||||
|
# copy artifact for redist
|
||||||
|
dll_artifact_filename = _generate_dll_artifact_name(target_name, target_triple)
|
||||||
|
yield FileCopyInfo(
|
||||||
|
target_directory / dll_artifact_filename,
|
||||||
|
Path("bin" if _is_windows_env(target_triple) else "lib")
|
||||||
|
/ dll_artifact_filename,
|
||||||
|
)
|
||||||
|
# copy artifact for linking only on windows
|
||||||
|
if _is_windows_env(target_triple):
|
||||||
|
lib_artifact_filename = _generate_lib_artifact_name(target_name, target_triple)
|
||||||
|
yield FileCopyInfo(
|
||||||
|
target_directory / lib_artifact_filename,
|
||||||
|
Path("lib") / lib_artifact_filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TextCopyInfo:
|
||||||
|
"""A piece of generated text to write into the distribution."""
|
||||||
|
|
||||||
|
text: str
|
||||||
|
"""The content of source file"""
|
||||||
|
to_path: Path
|
||||||
|
"""The path of destination file relative to the install directory"""
|
||||||
|
|
||||||
|
|
||||||
|
_BAD_NAME_PATTERN: Pattern = compile(r"[^a-zA-Z0-9_+-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_name(name: str) -> str:
|
||||||
|
"""Strip characters disallowed in generated identifiers from ``name``.
|
||||||
|
|
||||||
|
:raises ValueError: if nothing remains after stripping.
|
||||||
|
"""
|
||||||
|
new_name = _BAD_NAME_PATTERN.sub("", name)
|
||||||
|
if new_name == "":
|
||||||
|
raise ValueError("name is blank after sanitizing")
|
||||||
|
else:
|
||||||
|
return new_name
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
|
||||||
|
"""Yield :class:`TextCopyInfo` for the generated CMake ``Config`` and ``ConfigVersion`` files."""
|
||||||
|
extractor = ctx.extractor
|
||||||
|
metadata = ctx.metadata
|
||||||
|
target_name = extractor.get_target_name()
|
||||||
|
|
||||||
|
# get triple info
|
||||||
|
target_triple = extractor.get_host_triple()
|
||||||
|
if ctx.options.target is not None:
|
||||||
|
target_triple = ctx.options.target
|
||||||
|
|
||||||
|
# compute fallback name
|
||||||
|
cmake_fallback_name = _sanitize_name(target_name)
|
||||||
|
# get namespace and target name with fallback
|
||||||
|
cmake_namespace_name = cmake_fallback_name
|
||||||
|
cmake_target_name = cmake_fallback_name
|
||||||
|
if metadata.cmake is not None:
|
||||||
|
if metadata.cmake.namespace_name is not None:
|
||||||
|
cmake_namespace_name = metadata.cmake.namespace_name
|
||||||
|
if metadata.cmake.target_name is not None:
|
||||||
|
cmake_target_name = metadata.cmake.target_name
|
||||||
|
|
||||||
|
# gather declared dependencies (with metadata -> render conversion)
|
||||||
|
cmake_dependencies: tuple[CMakeDependency, ...] = tuple()
|
||||||
|
if metadata.cmake is not None and metadata.cmake.dependencies is not None:
|
||||||
|
cmake_dependencies = tuple(
|
||||||
|
CMakeDependency(dep.package, dep.target)
|
||||||
|
for dep in metadata.cmake.dependencies
|
||||||
|
)
|
||||||
|
|
||||||
|
# build cmake properties and render
|
||||||
|
properties = CMakeProperties(
|
||||||
|
cmake_namespace_name,
|
||||||
|
cmake_target_name,
|
||||||
|
_generate_dll_artifact_name(target_name, target_triple),
|
||||||
|
_generate_lib_artifact_name(target_name, target_triple),
|
||||||
|
extractor.get_version(),
|
||||||
|
cmake_dependencies,
|
||||||
|
)
|
||||||
|
render = CMakeRender(properties)
|
||||||
|
# return infos
|
||||||
|
yield TextCopyInfo(
|
||||||
|
render.render_config(),
|
||||||
|
Path(
|
||||||
|
"lib",
|
||||||
|
"cmake",
|
||||||
|
cmake_target_name,
|
||||||
|
f"{cmake_target_name}Config.cmake",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
yield TextCopyInfo(
|
||||||
|
render.render_config_version(),
|
||||||
|
Path(
|
||||||
|
"lib",
|
||||||
|
"cmake",
|
||||||
|
cmake_target_name,
|
||||||
|
f"{cmake_target_name}ConfigVersion.cmake",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_pkgconfig_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
|
||||||
|
"""Yield :class:`TextCopyInfo` for the generated pkg-config ``.pc`` file."""
|
||||||
|
extractor = ctx.extractor
|
||||||
|
metadata = ctx.metadata
|
||||||
|
target_name = extractor.get_target_name()
|
||||||
|
|
||||||
|
# compute fallback name and description
|
||||||
|
pkgconfig_id = _sanitize_name(target_name)
|
||||||
|
pkgconfig_name = _sanitize_name(target_name)
|
||||||
|
pkgconfig_description = extractor.get_description()
|
||||||
|
# get name and description with fallback
|
||||||
|
if metadata.pkgconfig is not None:
|
||||||
|
if metadata.pkgconfig.name is not None:
|
||||||
|
pkgconfig_name = metadata.pkgconfig.name
|
||||||
|
if metadata.pkgconfig.description is not None:
|
||||||
|
pkgconfig_description = metadata.pkgconfig.description
|
||||||
|
# correct None description
|
||||||
|
if pkgconfig_description is None:
|
||||||
|
pkgconfig_description = ""
|
||||||
|
|
||||||
|
pkgconfig_requires: tuple[str, ...] = tuple()
|
||||||
|
if metadata.pkgconfig is not None and metadata.pkgconfig.requires is not None:
|
||||||
|
pkgconfig_requires = metadata.pkgconfig.requires
|
||||||
|
|
||||||
|
properties = PkgConfigProperties(
|
||||||
|
pkgconfig_name,
|
||||||
|
pkgconfig_description,
|
||||||
|
target_name,
|
||||||
|
extractor.get_version(),
|
||||||
|
pkgconfig_requires,
|
||||||
|
)
|
||||||
|
render = PkgConfigRender(properties)
|
||||||
|
yield TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{pkgconfig_id}.pc"))
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Command-line interface for sarasacw-omrf-packer.
|
||||||
|
|
||||||
|
Defines :class:`Cli`, the immutable container of captured options, and
|
||||||
|
:func:`parse`, which interprets ``sys.argv`` via :mod:`argparse`. Downstream
|
||||||
|
stages consume a :class:`Cli` instance rather than reading ``sys.argv``
|
||||||
|
themselves, so the accepted options and their semantics live in one place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from .utils import Triple
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Cli:
|
||||||
|
"""Captured command-line options for the packer.
|
||||||
|
|
||||||
|
Instances are immutable and are produced only by :func:`parse`; every
|
||||||
|
downstream stage of the pipeline reads its configuration from this object
|
||||||
|
rather than re-interpreting the command line.
|
||||||
|
"""
|
||||||
|
|
||||||
|
manifest: Path
|
||||||
|
"""Path to the ``Cargo.toml`` manifest of the project being packed."""
|
||||||
|
|
||||||
|
dist_dir: Path | None
|
||||||
|
"""Destination directory for the distributable layout, or ``None`` when
|
||||||
|
no on-disk distribution tree is requested."""
|
||||||
|
|
||||||
|
dist_zip: Path | None
|
||||||
|
"""Destination of the zip archive bundling the distribution tree, or
|
||||||
|
``None`` when no archive is requested."""
|
||||||
|
|
||||||
|
target: Triple | None
|
||||||
|
"""Target triple to pack for (e.g. ``x86_64-pc-windows-msvc``), or ``None``
|
||||||
|
to pack for the host toolchain."""
|
||||||
|
|
||||||
|
|
||||||
|
def parse() -> Cli:
|
||||||
|
"""Parse command-line arguments into a :class:`Cli` instance.
|
||||||
|
|
||||||
|
Builds an :class:`argparse.ArgumentParser`, interprets ``sys.argv`` and
|
||||||
|
returns the captured options as an immutable :class:`Cli`. The process is
|
||||||
|
terminated by argparse itself on error or when ``-h/--help`` is given.
|
||||||
|
"""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
prog="SarasaCW OMRF Packer",
|
||||||
|
description=(
|
||||||
|
"Packaging tool of Sarasas Chip Workshop Oh My Rust FFI (OMRF). "
|
||||||
|
"Creates a CMake-style distributable layout and generates CMake "
|
||||||
|
"and pkg-config files for Rust FFI build artifacts."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-m",
|
||||||
|
"--manifest",
|
||||||
|
dest="manifest",
|
||||||
|
action="store",
|
||||||
|
type=Path,
|
||||||
|
required=True,
|
||||||
|
help="Path to the Cargo.toml manifest of the project to pack",
|
||||||
|
metavar="CARGO.TOML",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--dist-dir",
|
||||||
|
dest="dist_dir",
|
||||||
|
action="store",
|
||||||
|
type=Path,
|
||||||
|
required=False,
|
||||||
|
help="Directory where distributable files are installed (creates a CMake layout: bin/, include/, lib/ ...)",
|
||||||
|
metavar="DIR",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-z",
|
||||||
|
"--dist-zip",
|
||||||
|
dest="dist_zip",
|
||||||
|
action="store",
|
||||||
|
type=Path,
|
||||||
|
required=False,
|
||||||
|
help="Path of the zip archive created from the dist-dir contents",
|
||||||
|
metavar="ZIP",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--target",
|
||||||
|
dest="target",
|
||||||
|
action="store",
|
||||||
|
type=Triple.parse,
|
||||||
|
required=False,
|
||||||
|
help="Target triple to pack for (e.g. x86_64-pc-windows-msvc). Defaults to the host toolchain.",
|
||||||
|
metavar="TRIPLE",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
return Cli(
|
||||||
|
manifest=args.manifest,
|
||||||
|
dist_dir=args.dist_dir,
|
||||||
|
dist_zip=args.dist_zip,
|
||||||
|
target=args.target,
|
||||||
|
)
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
"""Extraction and typed representation of cargo/OMRF metadata.
|
||||||
|
|
||||||
|
Defines the frozen :class:`MetadataHeader`/:class:`MetadataCMake`/
|
||||||
|
:class:`MetadataPkgConfig`/:class:`Metadata` models built from the
|
||||||
|
``[package.metadata.omrf]`` table, and :class:`MetadataExtractor`, which runs
|
||||||
|
``cargo metadata`` and exposes the relevant fields. The
|
||||||
|
:func:`wrap_metadata_errors` decorator gives every raised exception a uniform
|
||||||
|
``error occurs when fetching metadata`` context.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import wraps
|
||||||
|
from pathlib import Path
|
||||||
|
from re import Pattern, compile
|
||||||
|
from typing import Any, Callable
|
||||||
|
from semver import Version
|
||||||
|
from . import utils
|
||||||
|
from .utils import (
|
||||||
|
Triple,
|
||||||
|
VERSION,
|
||||||
|
dict_chain_get,
|
||||||
|
dict_typed_get,
|
||||||
|
dict_typed_get_required,
|
||||||
|
list_typed_iter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadataHeader:
|
||||||
|
"""One ``headers`` asset entry from the OMRF metadata.
|
||||||
|
|
||||||
|
Describes a single header file (or, when ``from_path`` is a glob, a set of
|
||||||
|
files) to install into the ``include`` tree.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from_path: str
|
||||||
|
"""Source path relative to the project root; may be a UNIX-style glob."""
|
||||||
|
to_path: str
|
||||||
|
"""Destination path relative to the ``include`` directory (``""`` means the root)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate that ``from_path`` is non-empty."""
|
||||||
|
if self.from_path == "":
|
||||||
|
raise ValueError("header 'from' must not be empty")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "MetadataHeader":
|
||||||
|
"""Build a :class:`MetadataHeader` from its raw TOML table."""
|
||||||
|
return MetadataHeader(
|
||||||
|
from_path=dict_typed_get_required(d, "from", str),
|
||||||
|
to_path=dict_typed_get(d, "to", str, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadataCMakeDependency:
|
||||||
|
"""One CMake dependency declared in the OMRF metadata.
|
||||||
|
|
||||||
|
Carries the ``find_dependency`` package name and the link target to wire
|
||||||
|
into the imported target's ``INTERFACE_LINK_LIBRARIES``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
package: str
|
||||||
|
"""Package name passed to ``find_dependency`` (e.g. ``ZLIB``)."""
|
||||||
|
target: str
|
||||||
|
"""Link target wired into ``INTERFACE_LINK_LIBRARIES`` (e.g. ``ZLIB::ZLIB``)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate that ``package`` and ``target`` are non-empty strings."""
|
||||||
|
if self.package == "":
|
||||||
|
raise ValueError("bad cmake dependency package")
|
||||||
|
if self.target == "":
|
||||||
|
raise ValueError("bad cmake dependency target")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "MetadataCMakeDependency":
|
||||||
|
"""Build a :class:`CMakeDependency` from its raw TOML table."""
|
||||||
|
return MetadataCMakeDependency(
|
||||||
|
package=dict_typed_get_required(d, "package", str),
|
||||||
|
target=dict_typed_get_required(d, "target", str),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadataCMake:
|
||||||
|
"""Optional ``[package.metadata.omrf.cmake]`` section."""
|
||||||
|
|
||||||
|
namespace_name: str | None
|
||||||
|
"""CMake target namespace, or ``None`` to fall back to the project name."""
|
||||||
|
target_name: str | None
|
||||||
|
"""CMake target name, or ``None`` to fall back to the project name."""
|
||||||
|
dependencies: tuple[MetadataCMakeDependency, ...] | None
|
||||||
|
"""Declared CMake dependencies, or ``None`` to no dependencies specified."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate the namespace and target names when specified.
|
||||||
|
|
||||||
|
:raises ValueError: if a specified name is not a legal name.
|
||||||
|
"""
|
||||||
|
if self.namespace_name is not None and not utils.is_good_name(
|
||||||
|
self.namespace_name
|
||||||
|
):
|
||||||
|
raise ValueError("bad cmake namespace name")
|
||||||
|
if self.target_name is not None and not utils.is_good_name(self.target_name):
|
||||||
|
raise ValueError("bad cmake target name")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "MetadataCMake":
|
||||||
|
"""Build a :class:`MetadataCMake` from its raw TOML table."""
|
||||||
|
raw_dependencies = dict_typed_get(d, "dependencies", list)
|
||||||
|
dependencies = (
|
||||||
|
tuple(
|
||||||
|
MetadataCMakeDependency.from_dict(item)
|
||||||
|
for item in list_typed_iter(raw_dependencies, dict)
|
||||||
|
)
|
||||||
|
if raw_dependencies is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return MetadataCMake(
|
||||||
|
namespace_name=dict_typed_get(d, "namespace_name", str),
|
||||||
|
target_name=dict_typed_get(d, "target_name", str),
|
||||||
|
dependencies=dependencies,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadataPkgConfig:
|
||||||
|
"""Optional ``[package.metadata.omrf.pkgconfig]`` section."""
|
||||||
|
|
||||||
|
id: str | None
|
||||||
|
"""pkg-config package id (the ``.pc`` file stem), or ``None`` to fall back to the project name."""
|
||||||
|
name: str | None
|
||||||
|
"""Human-readable package name, or ``None`` to fall back to the project name."""
|
||||||
|
description: str | None
|
||||||
|
"""Brief package description, or ``None`` to fall back to the project description."""
|
||||||
|
requires: tuple[str, ...] | None
|
||||||
|
"""Declared public pkg-config dependencies, or ``None`` to no dependencies specified."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate the id, name and description when specified.
|
||||||
|
|
||||||
|
:raises ValueError: if a specified id is not a legal name, or a
|
||||||
|
specified name/description contains EOL characters.
|
||||||
|
"""
|
||||||
|
if self.id is not None and not utils.is_good_name(self.id):
|
||||||
|
raise ValueError("bad pkg-config id")
|
||||||
|
if self.name is not None and not utils.is_good_sentence(self.name):
|
||||||
|
raise ValueError("bad pkg-config name (has EOL chars)")
|
||||||
|
if self.description is not None and not utils.is_good_sentence(
|
||||||
|
self.description
|
||||||
|
):
|
||||||
|
raise ValueError("bad pkg-config description (has EOL chars)")
|
||||||
|
if self.requires is not None:
|
||||||
|
for req in self.requires:
|
||||||
|
if req == "":
|
||||||
|
raise ValueError("bad pkg-config require spec")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig":
|
||||||
|
"""Build a :class:`MetadataPkgConfig` from its raw TOML table."""
|
||||||
|
raw_requires = dict_typed_get(d, "requires", list)
|
||||||
|
requires = (
|
||||||
|
tuple(list_typed_iter(raw_requires, str))
|
||||||
|
if raw_requires is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return MetadataPkgConfig(
|
||||||
|
id=dict_typed_get(d, "id", str),
|
||||||
|
name=dict_typed_get(d, "name", str),
|
||||||
|
description=dict_typed_get(d, "description", str),
|
||||||
|
requires=requires,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Metadata:
|
||||||
|
"""Typed view of the whole ``[package.metadata.omrf]`` table."""
|
||||||
|
|
||||||
|
min_version: Version | None
|
||||||
|
"""Minimum packer version required by the project, or ``None`` for no constraint."""
|
||||||
|
headers: tuple[MetadataHeader, ...]
|
||||||
|
"""Header assets to distribute (required, possibly empty)."""
|
||||||
|
cmake: MetadataCMake | None
|
||||||
|
"""CMake override section, or ``None`` when absent."""
|
||||||
|
pkgconfig: MetadataPkgConfig | None
|
||||||
|
"""pkg-config override section, or ``None`` when absent."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Enforce ``min_version`` against the running packer version.
|
||||||
|
|
||||||
|
:raises RuntimeError: if ``min_version`` is set and newer than this packer.
|
||||||
|
"""
|
||||||
|
# check version restriction
|
||||||
|
this_version = self.min_version
|
||||||
|
if this_version is not None:
|
||||||
|
if this_version > VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"requested minimum version is not fulfilled. {this_version} required got {VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "Metadata":
|
||||||
|
"""Build a :class:`Metadata` from the raw ``[package.metadata.omrf]`` table."""
|
||||||
|
raw_min_version = dict_typed_get(d, "min_version", str)
|
||||||
|
if raw_min_version is not None:
|
||||||
|
min_version = utils.parse_version(raw_min_version)
|
||||||
|
else:
|
||||||
|
min_version = None
|
||||||
|
|
||||||
|
headers = tuple(
|
||||||
|
MetadataHeader.from_dict(item)
|
||||||
|
for item in list_typed_iter(
|
||||||
|
dict_typed_get_required(d, "headers", list), dict
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_cmake = dict_typed_get(d, "cmake", dict)
|
||||||
|
cmake = MetadataCMake.from_dict(raw_cmake) if raw_cmake is not None else None
|
||||||
|
|
||||||
|
raw_pkgconfig = dict_typed_get(d, "pkgconfig", dict)
|
||||||
|
pkgconfig = (
|
||||||
|
MetadataPkgConfig.from_dict(raw_pkgconfig)
|
||||||
|
if raw_pkgconfig is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
|
return Metadata(
|
||||||
|
min_version=min_version,
|
||||||
|
headers=headers,
|
||||||
|
cmake=cmake,
|
||||||
|
pkgconfig=pkgconfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
||||||
|
"""Decorator that wraps a function's exceptions in a uniform metadata context.
|
||||||
|
|
||||||
|
Any :class:`Exception` raised by the wrapped function is re-raised as
|
||||||
|
``RuntimeError("error occurs when fetching metadata: ...")`` chained to the
|
||||||
|
original cause.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
|
try:
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"error occurs when fetching metadata: {e}") from e
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
_SUBPROCESS_TIMEOUT_SEC: int = 10
|
||||||
|
"""Timeout in seconds for the ``cargo``/``rustc`` subprocesses launched below."""
|
||||||
|
|
||||||
|
_HOST_TRIPLE_PATTERN: Pattern = compile(r"(?m)^host:\s*(\S+)$")
|
||||||
|
"""The pattern for match host triple in ``rustc -vV``. The group 1 is the triple result."""
|
||||||
|
|
||||||
|
|
||||||
|
class MetadataExtractor:
|
||||||
|
"""Access layer over ``cargo metadata`` and the OMRF metadata table.
|
||||||
|
|
||||||
|
On construction it runs ``cargo metadata``, locates the user-requested
|
||||||
|
package and its main library target, and caches the raw dicts. The getter
|
||||||
|
methods expose typed views of individual fields; :meth:`get_metadata`
|
||||||
|
returns the parsed :class:`Metadata` model. Every getter is wrapped by
|
||||||
|
:func:`wrap_metadata_errors`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__metadata: dict[str, Any]
|
||||||
|
"""The direct output of ``cargo metadata``"""
|
||||||
|
__metadata_package: dict[str, Any]
|
||||||
|
"""The package item in cargo metadata's packages list pointing to user request package"""
|
||||||
|
__metadata_target: dict[str, Any]
|
||||||
|
"""The target item in package item's targets list pointing to the main target"""
|
||||||
|
__host_triple: Triple
|
||||||
|
"""The host toolchain triple resolved from ``rustc -vV``"""
|
||||||
|
|
||||||
|
def __init__(self, cargo_toml_path: Path) -> None:
|
||||||
|
"""Run ``cargo metadata`` and locate the package and main target.
|
||||||
|
|
||||||
|
:param cargo_toml_path: Path to the ``Cargo.toml`` of the project to pack.
|
||||||
|
"""
|
||||||
|
self.__metadata = MetadataExtractor.__extract_metadata(cargo_toml_path)
|
||||||
|
self.__metadata_package = MetadataExtractor.__extract_metadata_package(
|
||||||
|
self.__metadata, cargo_toml_path
|
||||||
|
)
|
||||||
|
self.__metadata_target = MetadataExtractor.__extract_metadata_target(
|
||||||
|
self.__metadata_package, cargo_toml_path
|
||||||
|
)
|
||||||
|
self.__host_triple = MetadataExtractor.__extract_host_triple()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def __extract_host_triple() -> Triple:
|
||||||
|
"""Query ``rustc -vV`` and parse its ``host:`` line into a :class:`Triple`.
|
||||||
|
|
||||||
|
:raises RuntimeError: if rustc times out, exits with a non-zero status,
|
||||||
|
or its output carries no ``host:`` line.
|
||||||
|
"""
|
||||||
|
rustc = os.getenv("OMRF_PACKER_RUSTC", "rustc")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[rustc, "-vV"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT_SEC)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.communicate()
|
||||||
|
raise RuntimeError("fail to fetch host triple: timed out")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"fail to fetch host triple: " + stderr.decode("utf-8", errors="ignore")
|
||||||
|
)
|
||||||
|
m = _HOST_TRIPLE_PATTERN.search(stdout.decode("utf-8", errors="strict"))
|
||||||
|
if m is None:
|
||||||
|
raise RuntimeError("can not find host triple in rustc -vV output")
|
||||||
|
return Triple.parse(m.group(1))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def __extract_metadata(cargo_toml_path: Path) -> dict[str, Any]:
|
||||||
|
"""Invoke ``cargo metadata`` and return its parsed JSON output.
|
||||||
|
|
||||||
|
:param cargo_toml_path: Path to the manifest passed via ``--manifest-path``.
|
||||||
|
:returns: Parsed ``cargo metadata`` output.
|
||||||
|
:raises RuntimeError: if cargo times out or exits with a non-zero status.
|
||||||
|
"""
|
||||||
|
cargo_bin = os.getenv("OMRF_PACKER_CARGO", "cargo")
|
||||||
|
cmd = [
|
||||||
|
cargo_bin,
|
||||||
|
"metadata",
|
||||||
|
"--no-deps",
|
||||||
|
"--format-version",
|
||||||
|
"1",
|
||||||
|
"--manifest-path",
|
||||||
|
str(cargo_toml_path),
|
||||||
|
]
|
||||||
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT_SEC)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.communicate()
|
||||||
|
raise RuntimeError("fail to fetch cargo metadata: timed out")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"fail to fetch cargo metadata: "
|
||||||
|
+ stderr.decode("utf-8", errors="ignore")
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
return json.loads(stdout)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def __extract_metadata_package(
|
||||||
|
cargo_metadata: dict[str, Any], cargo_toml_path: Path
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find the package whose ``manifest_path`` matches the given manifest.
|
||||||
|
|
||||||
|
:param cargo_metadata: Parsed ``cargo metadata`` output.
|
||||||
|
:param cargo_toml_path: Manifest path of the user-requested package.
|
||||||
|
:returns: The matching package item.
|
||||||
|
:raises RuntimeError: if no matching package is found.
|
||||||
|
"""
|
||||||
|
packages = dict_typed_get_required(cargo_metadata, "packages", list)
|
||||||
|
for package in list_typed_iter(packages, dict):
|
||||||
|
raw_manifest_path = dict_typed_get_required(package, "manifest_path", str)
|
||||||
|
manifest_path = Path(raw_manifest_path)
|
||||||
|
if manifest_path == cargo_toml_path:
|
||||||
|
return package
|
||||||
|
raise RuntimeError("can not find user given package in metadata")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def __extract_metadata_target(
|
||||||
|
cargo_package: dict[str, Any], cargo_toml_path: Path
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Find the library target (the one with ``src/lib.rs``) of a package.
|
||||||
|
|
||||||
|
:param cargo_package: A package item from ``cargo metadata``.
|
||||||
|
:param cargo_toml_path: Manifest path used to derive the ``src/lib.rs`` location.
|
||||||
|
:returns: The matching target item.
|
||||||
|
:raises RuntimeError: if no library target is found.
|
||||||
|
"""
|
||||||
|
# build the path to lib.rs for comparing
|
||||||
|
librs = cargo_toml_path.parent / "src" / "lib.rs"
|
||||||
|
# start checking
|
||||||
|
targets = dict_typed_get_required(cargo_package, "targets", list)
|
||||||
|
for target in list_typed_iter(targets, dict):
|
||||||
|
raw_src_path = dict_typed_get_required(target, "src_path", str)
|
||||||
|
src_path = Path(raw_src_path)
|
||||||
|
if src_path == librs:
|
||||||
|
return target
|
||||||
|
raise RuntimeError(
|
||||||
|
"can not find the main target of user given package in metadata"
|
||||||
|
)
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_target_directory(self) -> Path:
|
||||||
|
"""Get the absolute path to directory where Rust target directory is"""
|
||||||
|
raw_target_directory = dict_typed_get_required(
|
||||||
|
self.__metadata, "target_directory", str
|
||||||
|
)
|
||||||
|
return Path(raw_target_directory)
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return the package name as reported by cargo."""
|
||||||
|
return dict_typed_get_required(self.__metadata_package, "name", str)
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_description(self) -> str | None:
|
||||||
|
"""Return the package description, or ``None`` if unset."""
|
||||||
|
return dict_typed_get(self.__metadata_package, "description", str)
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_version(self) -> Version:
|
||||||
|
"""Return the package version as a validated :class:`semver.Version`."""
|
||||||
|
raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
|
||||||
|
return utils.parse_version(raw_version)
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_project_dir(self) -> Path:
|
||||||
|
"""Get the absolute path to directory where exported Cargo.toml is"""
|
||||||
|
raw_manifest_path = dict_typed_get_required(
|
||||||
|
self.__metadata_package, "manifest_path", str
|
||||||
|
)
|
||||||
|
return Path(raw_manifest_path).parent
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_metadata(self) -> Metadata:
|
||||||
|
"""Return the parsed :class:`Metadata` model from the OMRF table."""
|
||||||
|
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
|
||||||
|
return Metadata.from_dict(omrf)
|
||||||
|
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def get_target_name(self) -> str:
|
||||||
|
"""Return the name of the main library target."""
|
||||||
|
return dict_typed_get_required(self.__metadata_target, "name", str)
|
||||||
|
|
||||||
|
def get_host_triple(self) -> Triple:
|
||||||
|
"""Return the host toolchain triple resolved at construction time."""
|
||||||
|
return self.__host_triple
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""Renderer for CMake package-configuration files.
|
||||||
|
|
||||||
|
Generates the ``<pkg>Config.cmake`` and ``<pkg>ConfigVersion.cmake`` files that
|
||||||
|
allow a downstream CMake project to ``find_package`` the distributed Rust FFI
|
||||||
|
library. The data fed to the templates is carried by
|
||||||
|
:class:`CMakeProperties`, and :class:`CMakeRender` performs the rendering on
|
||||||
|
top of :class:`renders.common.BaseRender`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from semver import Version
|
||||||
|
from . import common
|
||||||
|
from .. import utils
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CMakeDependency:
|
||||||
|
"""One CMake dependency consumed by the renderer.
|
||||||
|
|
||||||
|
Carries the ``find_dependency`` package name and the link target wired into
|
||||||
|
the imported target's ``INTERFACE_LINK_LIBRARIES``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
package: str
|
||||||
|
"""Package name passed to ``find_dependency`` (e.g. ``ZLIB``)."""
|
||||||
|
target: str
|
||||||
|
"""Link target wired into ``INTERFACE_LINK_LIBRARIES`` (e.g. ``ZLIB::ZLIB``)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate that ``package`` and ``target`` are non-empty strings."""
|
||||||
|
if self.package == "":
|
||||||
|
raise ValueError("bad cmake dependency package")
|
||||||
|
if self.target == "":
|
||||||
|
raise ValueError("bad cmake dependency target")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CMakeProperties:
|
||||||
|
"""Inputs required to render the CMake package-configuration files.
|
||||||
|
|
||||||
|
A frozen dataclass validated in :meth:`__post_init__`; once constructed, an
|
||||||
|
instance is guaranteed to hold only values that are safe to substitute into
|
||||||
|
the CMake templates.
|
||||||
|
"""
|
||||||
|
|
||||||
|
namespace_name: str
|
||||||
|
"""Namespace component used when generating the CMake scripts."""
|
||||||
|
target_name: str
|
||||||
|
"""Target-name component used when generating the CMake scripts."""
|
||||||
|
|
||||||
|
artifact_dll: str
|
||||||
|
"""File name of the dynamic-loadable artifact, including its platform-dependent suffix.
|
||||||
|
|
||||||
|
Typically ``example.dll`` on Windows, ``example.so`` on Linux, and
|
||||||
|
``example.dylib`` on macOS.
|
||||||
|
"""
|
||||||
|
artifact_lib: str
|
||||||
|
"""File name of the import-library artifact, including its platform-dependent suffix.
|
||||||
|
|
||||||
|
Typically the dynamic-loadable file name with its suffix replaced by
|
||||||
|
``.lib``. This field is unused on UNIX-like systems, but it must still be
|
||||||
|
provided or rendering will fail.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version: Version
|
||||||
|
"""Version of the library. Must not carry a prerelease or build component."""
|
||||||
|
dependencies: tuple[CMakeDependency, ...]
|
||||||
|
"""CMake dependencies to ``find_dependency`` and link into the target
|
||||||
|
(empty when none are declared)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate the fields after construction.
|
||||||
|
|
||||||
|
:raises ValueError: if the namespace or target name contains characters
|
||||||
|
outside the CMake name pattern, an artifact file name is empty, or the
|
||||||
|
version carries a prerelease or build component (unsupported by
|
||||||
|
CMake config-version files).
|
||||||
|
"""
|
||||||
|
if not utils.is_good_name(self.namespace_name):
|
||||||
|
raise ValueError(
|
||||||
|
"bad namespace component in CMake. consider manually specifying it"
|
||||||
|
)
|
||||||
|
if not utils.is_good_name(self.target_name):
|
||||||
|
raise ValueError(
|
||||||
|
"bad target name component in CMake. consider manually specifying it"
|
||||||
|
)
|
||||||
|
if self.artifact_dll == "":
|
||||||
|
raise ValueError("bad artifact dll file name in CMake")
|
||||||
|
if self.artifact_lib == "":
|
||||||
|
raise ValueError("bad artifact lib file name in CMake")
|
||||||
|
if not utils.is_good_version(self.version):
|
||||||
|
raise ValueError(
|
||||||
|
"unsupported semantic version components (prerelease or build) in CMake"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CMakeRender:
|
||||||
|
"""Renderer that turns :class:`CMakeProperties` into CMake config files.
|
||||||
|
|
||||||
|
Wraps a :class:`renders.common.BaseRender` and exposes one method per
|
||||||
|
generated file (:meth:`render_config`, :meth:`render_config_version`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__render: common.BaseRender
|
||||||
|
__properties: CMakeProperties
|
||||||
|
|
||||||
|
def __init__(self, properties: CMakeProperties) -> None:
|
||||||
|
"""Create the renderer with its Jinja2 backend and bound properties.
|
||||||
|
|
||||||
|
:param properties: Validated inputs reused by every render method.
|
||||||
|
"""
|
||||||
|
self.__render = common.BaseRender()
|
||||||
|
self.__properties = properties
|
||||||
|
|
||||||
|
def render_config(self) -> str:
|
||||||
|
"""Render the ``<pkg>Config.cmake`` file.
|
||||||
|
|
||||||
|
:returns: The rendered ``Config.cmake`` content as a string.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"namespace": self.__properties.namespace_name,
|
||||||
|
"target": self.__properties.target_name,
|
||||||
|
"artifact_dll": self.__properties.artifact_dll,
|
||||||
|
"artifact_lib": self.__properties.artifact_lib,
|
||||||
|
"dependencies": self.__properties.dependencies,
|
||||||
|
}
|
||||||
|
return self.__render.render("XXXConfig.cmake.jinja", payload)
|
||||||
|
|
||||||
|
def render_config_version(self) -> str:
|
||||||
|
"""Render the ``<pkg>ConfigVersion.cmake`` file.
|
||||||
|
|
||||||
|
:returns: The rendered ``ConfigVersion.cmake`` content as a string.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {"version": str(self.__properties.version)}
|
||||||
|
return self.__render.render("XXXConfigVersion.cmake.jinja", payload)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Shared infrastructure for the template-based renderers.
|
||||||
|
|
||||||
|
Concrete renderers such as :class:`renders.cmake.CMakeRender` only decide which
|
||||||
|
template to render and with which payload; the actual Jinja2 plumbing -- the
|
||||||
|
loader, the environment and the template lookup -- is centralized here in
|
||||||
|
:class:`BaseRender`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
import jinja2
|
||||||
|
from .. import utils
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRender:
|
||||||
|
"""Jinja2-backed renderer shared by every concrete renderer.
|
||||||
|
|
||||||
|
A single Jinja2 :class:`~jinja2.Environment` is created at construction
|
||||||
|
time, backed by a :class:`~jinja2.FileSystemLoader` rooted at the
|
||||||
|
package's template directory. The environment is reused across all
|
||||||
|
:meth:`render` calls.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__loader: jinja2.BaseLoader
|
||||||
|
__environment: jinja2.Environment
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
"""Create the Jinja2 loader and environment.
|
||||||
|
|
||||||
|
The environment is configured to load templates from the directory
|
||||||
|
returned by :func:`utils.get_templates_dir`.
|
||||||
|
"""
|
||||||
|
self.__loader = jinja2.FileSystemLoader(utils.get_templates_dir())
|
||||||
|
self.__environment = jinja2.Environment(loader=self.__loader)
|
||||||
|
|
||||||
|
def render(self, template_filename: str, payload: dict[str, Any]) -> str:
|
||||||
|
"""Render a named template with the given payload.
|
||||||
|
|
||||||
|
:param template_filename: Name of the template file, relative to the
|
||||||
|
package template directory.
|
||||||
|
:param payload: Mapping of variable names to their values, forwarded
|
||||||
|
to the template as its context.
|
||||||
|
:returns: The rendered template as a string.
|
||||||
|
"""
|
||||||
|
# fetch template
|
||||||
|
template = self.__environment.get_template(template_filename)
|
||||||
|
# render template and return
|
||||||
|
return template.render(**payload)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Renderer for pkg-config package files.
|
||||||
|
|
||||||
|
Generates the ``<pkg>.pc`` file consumed by the ``pkg-config`` tool so that the
|
||||||
|
distributed Rust FFI library can be located and linked by downstream build
|
||||||
|
systems. The data fed to the template is carried by
|
||||||
|
:class:`PkgConfigProperties`, and :class:`PkgConfigRender` performs the
|
||||||
|
rendering on top of :class:`renders.common.BaseRender`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
from . import common
|
||||||
|
from .. import utils
|
||||||
|
from semver import Version
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PkgConfigProperties:
|
||||||
|
"""Inputs required to render the pkg-config ``.pc`` file.
|
||||||
|
|
||||||
|
A frozen dataclass validated in :meth:`__post_init__`; once constructed, an
|
||||||
|
instance is guaranteed to hold only values that are safe to substitute into
|
||||||
|
the ``.pc`` template.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
"""Human-readable name of the library or package.
|
||||||
|
|
||||||
|
This is purely descriptive; the ``pkg-config`` tool itself resolves
|
||||||
|
packages by the file name of the ``.pc`` file, not by this field.
|
||||||
|
"""
|
||||||
|
description: str
|
||||||
|
"""Brief description of the package."""
|
||||||
|
|
||||||
|
artifact: str
|
||||||
|
"""Name of the build artifact.
|
||||||
|
|
||||||
|
``pkg-config`` uses this value to determine which dynamic library to link.
|
||||||
|
For an artifact named ``XXX``, it looks for ``libXXX.so`` on Linux and
|
||||||
|
``XXX.lib`` or ``libXXX.dll.a`` on Windows.
|
||||||
|
"""
|
||||||
|
|
||||||
|
version: Version
|
||||||
|
"""Version of the library. Must not carry a prerelease or build component."""
|
||||||
|
requires: tuple[str, ...]
|
||||||
|
"""Public pkg-config dependencies (empty when none are declared)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate the fields after construction.
|
||||||
|
|
||||||
|
:raises ValueError: if the name or description is blank, or the version
|
||||||
|
carries a prerelease or build component (unsupported in pkg-config).
|
||||||
|
"""
|
||||||
|
if self.name == "" or not utils.is_good_sentence(self.name):
|
||||||
|
raise ValueError("bad name (blank or has EOL chars) of pkg-config")
|
||||||
|
if self.description == "" or not utils.is_good_sentence(self.description):
|
||||||
|
raise ValueError("bad description (blank or has EOL chars) of pkg-config")
|
||||||
|
if not utils.is_good_name(self.artifact):
|
||||||
|
raise ValueError("bad artifact name in pkg-config")
|
||||||
|
if not utils.is_good_version(self.version):
|
||||||
|
raise ValueError(
|
||||||
|
"unsupported semantic version components (prerelease or build) in pkg-config"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PkgConfigRender:
|
||||||
|
"""Renderer that turns :class:`PkgConfigProperties` into a ``.pc`` file.
|
||||||
|
|
||||||
|
Wraps a :class:`renders.common.BaseRender` and exposes :meth:`render` to
|
||||||
|
emit the package configuration file.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__render: common.BaseRender
|
||||||
|
__properties: PkgConfigProperties
|
||||||
|
|
||||||
|
def __init__(self, properties: PkgConfigProperties) -> None:
|
||||||
|
"""Create the renderer with its Jinja2 backend and bound properties.
|
||||||
|
|
||||||
|
:param properties: Validated inputs reused by :meth:`render`.
|
||||||
|
"""
|
||||||
|
self.__render = common.BaseRender()
|
||||||
|
self.__properties = properties
|
||||||
|
|
||||||
|
def render(self) -> str:
|
||||||
|
"""Render the ``<pkg>.pc`` file.
|
||||||
|
|
||||||
|
:returns: The rendered ``.pc`` content as a string.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"name": self.__properties.name,
|
||||||
|
"description": self.__properties.description,
|
||||||
|
"version": str(self.__properties.version),
|
||||||
|
"artifact": self.__properties.artifact,
|
||||||
|
"requires": self.__properties.requires,
|
||||||
|
}
|
||||||
|
return self.__render.render("XXX.pc.jinja", payload)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# This file is created by sarasacw-omrf-packer and should not be changed manually.
|
||||||
|
|
||||||
|
{#
|
||||||
|
Utilize ${pcfiledir} to fetch the directory where current .pc file is.
|
||||||
|
And back to parent twice to obtain the installation directory.
|
||||||
|
-#}
|
||||||
|
prefix=${pcfiledir}/../..
|
||||||
|
exec_prefix=${prefix}
|
||||||
|
libdir=${exec_prefix}/lib
|
||||||
|
includedir=${prefix}/include
|
||||||
|
|
||||||
|
Name: {{ name }}
|
||||||
|
Description: {{ description }}
|
||||||
|
Version: {{ version }}
|
||||||
|
{%- if requires | length != 0 %}
|
||||||
|
Requires: {{ requires | join(", ") }}
|
||||||
|
{%- endif %}
|
||||||
|
Libs: -L${libdir} -l{{ artifact }}
|
||||||
|
Cflags: -I${includedir}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# This file is created by sarasacw-omrf-packer and should not be changed manually.
|
||||||
|
|
||||||
|
{# Check whether this target is already exported -#}
|
||||||
|
if(TARGET {{ namespace }}::{{ target }})
|
||||||
|
message(STATUS "Target {{ namespace }}::{{ target }} is already defined.")
|
||||||
|
message(STATUS "We will reuse it. Please check their version for compatibility.")
|
||||||
|
return()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
{#
|
||||||
|
Get the path to directory where current XXXConfig.cmake file is (i.e. /path/to/installation/lib/cmake/XXX)
|
||||||
|
And compute installation root directory (back to parent 3 times, i.e. /path/to/installation)
|
||||||
|
-#}
|
||||||
|
get_filename_component(_IMPORT_PREFIX "${CMAKE_CURRENT_LIST_FILE}/../../../" ABSOLUTE)
|
||||||
|
|
||||||
|
{# Setup library and header file paths -#}
|
||||||
|
set(MyRust_LIBRARY "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}")
|
||||||
|
set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include")
|
||||||
|
|
||||||
|
{# Create modern CMake target (IMPORTED target) -#}
|
||||||
|
add_library({{ target }} SHARED IMPORTED)
|
||||||
|
add_library({{ namespace }}::{{ target }} ALIAS {{ target }})
|
||||||
|
set_target_properties({{ target }} PROPERTIES
|
||||||
|
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
|
||||||
|
)
|
||||||
|
{# Handle Windows and UNIX-like respectively -#}
|
||||||
|
if(WIN32)
|
||||||
|
set_target_properties({{ target }} PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}"
|
||||||
|
IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}"
|
||||||
|
)
|
||||||
|
else()
|
||||||
|
set_target_properties({{ target }} PROPERTIES
|
||||||
|
IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
{# Cleanup temporary variables -#}
|
||||||
|
set(_IMPORT_PREFIX)
|
||||||
|
|
||||||
|
{% if dependencies | length != 0 -%}
|
||||||
|
{# Pull in declared dependencies and wire them into the imported target -#}
|
||||||
|
include(CMakeFindDependencyMacro)
|
||||||
|
{% for dep in dependencies -%}
|
||||||
|
find_dependency({{ dep.package }})
|
||||||
|
{% endfor -%}
|
||||||
|
set_target_properties({{ target }} PROPERTIES
|
||||||
|
INTERFACE_LINK_LIBRARIES "{{ dependencies | map(attribute='target') | join(';') }}"
|
||||||
|
)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{# Verify component (although there is no component) -#}
|
||||||
|
macro(check_required_components _NAME)
|
||||||
|
foreach(comp ${${_NAME}_FIND_COMPONENTS})
|
||||||
|
if(NOT ${_NAME}_${comp}_FOUND)
|
||||||
|
if(${_NAME}_FIND_REQUIRED_${comp})
|
||||||
|
set(${_NAME}_FOUND FALSE)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endmacro()
|
||||||
|
check_required_components({{ target }})
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# This is a basic version file for the Config-mode of find_package().
|
||||||
|
# It is created by sarasacw-omrf-packer and should not be changed manually.
|
||||||
|
#
|
||||||
|
# This file sets PACKAGE_VERSION_EXACT if the current version string and
|
||||||
|
# the requested version string are exactly the same and it sets
|
||||||
|
# PACKAGE_VERSION_COMPATIBLE if the current version is >= requested version.
|
||||||
|
|
||||||
|
{# Setup package version -#}
|
||||||
|
set(PACKAGE_VERSION "{{- version -}}")
|
||||||
|
|
||||||
|
{# Check package version (code are copied from CMake generation) -#}
|
||||||
|
if (PACKAGE_FIND_VERSION_RANGE)
|
||||||
|
# Package version must be in the requested version range
|
||||||
|
if ((PACKAGE_FIND_VERSION_RANGE_MIN STREQUAL "INCLUDE" AND PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION_MIN)
|
||||||
|
OR ((PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "INCLUDE" AND PACKAGE_VERSION VERSION_GREATER PACKAGE_FIND_VERSION_MAX)
|
||||||
|
OR (PACKAGE_FIND_VERSION_RANGE_MAX STREQUAL "EXCLUDE" AND PACKAGE_VERSION VERSION_GREATER_EQUAL PACKAGE_FIND_VERSION_MAX)))
|
||||||
|
set(PACKAGE_VERSION_COMPATIBLE FALSE)
|
||||||
|
else()
|
||||||
|
set(PACKAGE_VERSION_COMPATIBLE TRUE)
|
||||||
|
endif()
|
||||||
|
else()
|
||||||
|
if(PACKAGE_VERSION VERSION_LESS PACKAGE_FIND_VERSION)
|
||||||
|
set(PACKAGE_VERSION_COMPATIBLE FALSE)
|
||||||
|
else()
|
||||||
|
set(PACKAGE_VERSION_COMPATIBLE TRUE)
|
||||||
|
if(PACKAGE_FIND_VERSION STREQUAL PACKAGE_VERSION)
|
||||||
|
set(PACKAGE_VERSION_EXACT TRUE)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Shared helpers used across sarasacw-omrf-packer.
|
||||||
|
|
||||||
|
Provides version parsing/checking, typed dictionary access helpers, name
|
||||||
|
validation and template-directory resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from re import Pattern, compile
|
||||||
|
from typing import Any, Iterator, overload
|
||||||
|
from semver import Version
|
||||||
|
|
||||||
|
VERSION: Version = Version(1, 0, 0)
|
||||||
|
"""The current version of sarasacw-omrf-packer"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_version(vs: str) -> Version:
|
||||||
|
"""This package specific version parser which explicit only support x.x.x style version."""
|
||||||
|
v = Version.parse(vs)
|
||||||
|
if v.prerelease is not None or v.build is not None:
|
||||||
|
raise ValueError("prerelease and build component of version is not supported")
|
||||||
|
else:
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def is_good_version(v: Version) -> bool:
|
||||||
|
"""Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package.
|
||||||
|
|
||||||
|
:returns: True if given string do not have these components, otherwise false.
|
||||||
|
"""
|
||||||
|
return v.prerelease is None and v.build is None
|
||||||
|
|
||||||
|
|
||||||
|
def get_root_dir() -> Path:
|
||||||
|
"""Return the resolved directory that contains this package."""
|
||||||
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def get_templates_dir() -> Path:
|
||||||
|
"""Return the directory holding the Jinja2 templates (``<root>/templates``)."""
|
||||||
|
return get_root_dir() / "templates"
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ...
|
||||||
|
@overload
|
||||||
|
def dict_typed_get[T, D](
|
||||||
|
d: dict[str, Any], key: str, ty: type[T], default: D
|
||||||
|
) -> T | D: ...
|
||||||
|
def dict_typed_get[T](
|
||||||
|
d: dict[str, Any], key: str, ty: type[T], default: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""Fetch ``key`` from ``d`` with an optional default, checking its type.
|
||||||
|
|
||||||
|
When the key is missing (or maps to ``None``) ``default`` is returned. When
|
||||||
|
present, the value must be an instance of ``ty`` or a :class:`TypeError` is
|
||||||
|
raised.
|
||||||
|
|
||||||
|
:param d: Dictionary to read from.
|
||||||
|
:param key: Key to look up.
|
||||||
|
:param ty: Expected type of the value; used for the ``isinstance`` check.
|
||||||
|
:param default: Value returned when the key is absent (defaults to ``None``).
|
||||||
|
:returns: The stored value (typed ``ty``) or ``default``.
|
||||||
|
:raises TypeError: if the stored value is not an instance of ``ty``.
|
||||||
|
"""
|
||||||
|
tmp = d.get(key, None)
|
||||||
|
if tmp is None:
|
||||||
|
return default
|
||||||
|
if not isinstance(tmp, ty):
|
||||||
|
raise TypeError(f'the value of key "{key}" is not a {ty.__name__}')
|
||||||
|
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
|
||||||
|
def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
|
||||||
|
"""Fetch ``key`` from ``d``, requiring it to be present and correctly typed.
|
||||||
|
|
||||||
|
Like :func:`dict_typed_get` but raises :class:`ValueError` when the key is
|
||||||
|
missing (or maps to ``None``) instead of returning a default.
|
||||||
|
|
||||||
|
:param d: Dictionary to read from.
|
||||||
|
:param key: Key to look up.
|
||||||
|
:param ty: Expected type of the value.
|
||||||
|
:returns: The stored value, typed ``ty``.
|
||||||
|
:raises ValueError: if the key is absent.
|
||||||
|
:raises TypeError: if the stored value is not an instance of ``ty``.
|
||||||
|
"""
|
||||||
|
result = dict_typed_get(d, key, ty)
|
||||||
|
if result is None:
|
||||||
|
raise ValueError(f'can not find key "{key}" in given dictionary')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]:
|
||||||
|
"""Descend through a chain of keys, each required to be a ``dict``.
|
||||||
|
|
||||||
|
Equivalent to calling :func:`dict_typed_get_required` with ``dict`` on each
|
||||||
|
key in turn.
|
||||||
|
|
||||||
|
:param d: Dictionary to start from.
|
||||||
|
:param args: Keys to traverse, in order.
|
||||||
|
:returns: The nested dictionary reached after following every key.
|
||||||
|
"""
|
||||||
|
for arg in args:
|
||||||
|
d = dict_typed_get_required(d, arg, dict)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def list_typed_iter[T](lst: list[Any], ty: type[T]) -> Iterator[T]:
|
||||||
|
"""Yield each element of ``lst`` after checking it is an instance of ``ty``.
|
||||||
|
|
||||||
|
:param lst: The list to iterate.
|
||||||
|
:param ty: Expected type of every element.
|
||||||
|
:returns: An iterator over the validated elements, typed ``T``.
|
||||||
|
:raises TypeError: if an element is not an instance of ``ty``.
|
||||||
|
"""
|
||||||
|
for i, item in enumerate(lst):
|
||||||
|
if not isinstance(item, ty):
|
||||||
|
raise TypeError(f"item {i} of the given list is not a {ty.__name__}")
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_+-]+")
|
||||||
|
|
||||||
|
|
||||||
|
def is_good_name(name: str) -> bool:
|
||||||
|
"""This package specific name checker
|
||||||
|
|
||||||
|
These allowed chars are picked as the subset of allowed chars in all generated contents,
|
||||||
|
including file name restrictions, CMake name restrictions and etc.
|
||||||
|
:returns: True if given name is legal, otherwise false.
|
||||||
|
"""
|
||||||
|
return _NAME_PATTERN.fullmatch(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
_EOL_PATTERN: Pattern = compile(r"[\r\n]")
|
||||||
|
|
||||||
|
|
||||||
|
def is_good_sentence(s: str) -> bool:
|
||||||
|
"""Check whether given string has EOL chars.
|
||||||
|
|
||||||
|
This function is used for checking human-readable name and description,
|
||||||
|
because EOL chars are not allowed in these properties.
|
||||||
|
:returns: True if given string don't contain any EOL chars, otherwise false.
|
||||||
|
"""
|
||||||
|
return _EOL_PATTERN.search(s) is None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Triple:
|
||||||
|
"""A Rust target triple (e.g. ``x86_64-pc-windows-msvc``).
|
||||||
|
|
||||||
|
Decomposes a triple into its ``arch``/``vendor``/``operating_system``/
|
||||||
|
``env`` components. Use :meth:`parse` to build one from a string and
|
||||||
|
``str(triple)`` to reconstruct the canonical dashed form.
|
||||||
|
"""
|
||||||
|
|
||||||
|
architecture: str
|
||||||
|
"""Architecture component (e.g. ``x86_64``, ``aarch64``)."""
|
||||||
|
vendor: str
|
||||||
|
"""Vendor component (e.g. ``pc``, ``unknown``, ``apple``)."""
|
||||||
|
operating_system: str
|
||||||
|
"""Operating-system component (e.g. ``windows``, ``linux``, ``darwin``)."""
|
||||||
|
environment: str | None
|
||||||
|
"""Environment/toolchain component (e.g. ``gnu``, ``msvc``), or ``None`` for
|
||||||
|
3-component triples."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
base = f"{self.architecture}-{self.vendor}-{self.operating_system}"
|
||||||
|
return f"{base}-{self.environment}" if self.environment is not None else base
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(s: str) -> "Triple":
|
||||||
|
"""Parse a triple string into a :class:`Triple`.
|
||||||
|
|
||||||
|
:raises ValueError: if the string has fewer than 3 or more than 4
|
||||||
|
dash-separated components.
|
||||||
|
"""
|
||||||
|
parts = s.split("-")
|
||||||
|
if len(parts) < 3 or len(parts) > 4:
|
||||||
|
raise ValueError(f"invalid target triple: {s!r}")
|
||||||
|
env = parts[3] if len(parts) == 4 else None
|
||||||
|
return Triple(
|
||||||
|
architecture=parts[0],
|
||||||
|
vendor=parts[1],
|
||||||
|
operating_system=parts[2],
|
||||||
|
environment=env,
|
||||||
|
)
|
||||||
Generated
+16
-3
@@ -68,11 +68,24 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sarasacw-omrf-packer"
|
name = "sarasacw-omrf-packer"
|
||||||
version = "0.1.0"
|
version = "1.0.0"
|
||||||
source = { virtual = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "jinja2" },
|
{ name = "jinja2" },
|
||||||
|
{ name = "semver" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [{ name = "jinja2", specifier = "==3.1.6" }]
|
requires-dist = [
|
||||||
|
{ name = "jinja2", specifier = "==3.1.6" },
|
||||||
|
{ name = "semver", specifier = ">=3.0.4" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "semver"
|
||||||
|
version = "3.0.4"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/d1/d3159231aec234a59dd7d601e9dd9fe96f3afff15efd33c1070019b26132/semver-3.0.4.tar.gz", hash = "sha256:afc7d8c584a5ed0a11033af086e8af226a9c0b206f313e0301f8dd7b6b589602", size = 269730, upload-time = "2025-01-24T13:19:27.617Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a6/24/4d91e05817e92e3a61c8a21e08fd0f390f5301f1c448b137c57c4bc6e543/semver-3.0.4-py3-none-any.whl", hash = "sha256:9c824d87ba7f7ab4a1890799cec8596f15c1241cb473404ea1cb0c55e4b04746", size = 17912, upload-time = "2025-01-24T13:19:24.949Z" },
|
||||||
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user