Compare commits

...
15 Commits
22 changed files with 1073 additions and 50 deletions
+14 -1
View File
@@ -2,4 +2,17 @@
## Bump Version Up ## Bump Version Up
TODO ### 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.
+13
View File
@@ -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)
View File
View File
+1
View File
@@ -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
View File
@@ -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]
+155
View File
@@ -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
}
}
+155
View File
@@ -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();
}
+73 -1
View File
@@ -1,5 +1,52 @@
# sarasacw-omrf-packer # 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 ## Metadata
All packer used properties are stored in target Rust manifest file as metadata style. All packer used properties are stored in target Rust manifest file as metadata style.
@@ -39,6 +86,20 @@ namespace_name = "foobar"
# Target-name part of the generated CMake target. # Target-name part of the generated CMake target.
# This property is optional; it defaults to the name of the target Rust project. # This property is optional; it defaults to the name of the target Rust project.
target_name = "foobar" 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] [package.metadata.omrf.pkgconfig]
# The unique identifier of the package. # The unique identifier of the package.
@@ -50,9 +111,20 @@ name = "Foo Bar"
# Brief description of the package. # Brief description of the package.
# This property is optional; it defaults to the description of the target Rust project. # This property is optional; it defaults to the description of the target Rust project.
description = "a brown fox jumps over a lazy dog." 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 ## 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_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`.
+20 -1
View File
@@ -1,12 +1,26 @@
[project] [project]
name = "sarasacw-omrf-packer" name = "sarasacw-omrf-packer"
version = "1.0.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 = [ authors = [
{ name = "yyc12345", email = "yyc12321@outlook.com" } { 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", "semver>=3.0.4",
@@ -15,6 +29,11 @@ dependencies = [
[project.scripts] [project.scripts]
sarasacw-omrf-packer = "sarasacw_omrf_packer:main" 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] [build-system]
requires = ["uv_build>=0.8.0,<0.9"] requires = ["uv_build>=0.8.0,<0.9"]
build-backend = "uv_build" build-backend = "uv_build"
+1 -1
View File
@@ -41,7 +41,7 @@ class App:
self.__opts = opts self.__opts = opts
# build essential instances # build essential instances
self.__extractor = MetadataExtractor(self.__opts.manifest) self.__extractor = MetadataExtractor(self.__opts.manifest)
self.__ctx = ArtifactContext(self.__extractor) self.__ctx = ArtifactContext(self.__opts, self.__extractor)
def run(self) -> None: def run(self) -> None:
"""Produce the distribution. """Produce the distribution.
+77 -17
View File
@@ -5,31 +5,39 @@ header copies, library copies and generated CMake/pkg-config texts that the
archiver then materializes. archiver then materializes.
""" """
import sys
import logging import logging
from typing import Iterator from typing import Iterator
from pathlib import Path from pathlib import Path
from dataclasses import dataclass from dataclasses import dataclass
from re import Pattern, compile from re import Pattern, compile
from .cli import Cli
from .utils import Triple
from .metadata import MetadataExtractor, Metadata from .metadata import MetadataExtractor, Metadata
from .renders.cmake import CMakeProperties, CMakeRender from .renders.cmake import CMakeDependency, CMakeProperties, CMakeRender
from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender
class ArtifactContext: class ArtifactContext:
"""A wrapper of metadata with proper fallback""" """A wrapper of metadata with proper fallback"""
__opts: Cli
__extractor: MetadataExtractor __extractor: MetadataExtractor
__metadata: Metadata __metadata: Metadata
def __init__(self, extractor: MetadataExtractor) -> None: def __init__(self, opts: Cli, extractor: MetadataExtractor) -> None:
"""Wrap an extractor and eagerly fetch its :class:`Metadata`. """Wrap an extractor and eagerly fetch its :class:`Metadata`.
:param extractor: The extractor to wrap and read from. :param extractor: The extractor to wrap and read from.
""" """
self.__opts = opts
self.__extractor = extractor self.__extractor = extractor
self.__metadata = self.__extractor.get_metadata() self.__metadata = self.__extractor.get_metadata()
@property
def options(self) -> Cli:
"""The wrapped :class:`Cli` holding all command line arguments"""
return self.__opts
@property @property
def extractor(self) -> MetadataExtractor: def extractor(self) -> MetadataExtractor:
"""The wrapped :class:`MetadataExtractor`.""" """The wrapped :class:`MetadataExtractor`."""
@@ -112,20 +120,39 @@ def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
) )
def _generate_dll_artifact_name(name: str) -> str: _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``.""" """Return the platform-appropriate dynamic-loadable file name for ``name``."""
match sys.platform: match triple.operating_system:
case "win32" | "cygwin": case x if x in _WINDOWS_ENV_IDENTS:
return f"{name}.dll" return f"{name}.dll"
case "linux" | "android" | "freebsd": case x if x in _LINUX_ENV_IDENTS:
return f"lib{name}.so" return f"lib{name}.so"
case "darwin" | "ios": case x if x in _MACOS_ENV_IDENTS:
return f"lib{name}.dylib" return f"lib{name}.dylib"
case _: case _:
raise RuntimeError("not supported system") raise RuntimeError("not supported system")
def _generate_lib_artifact_name(name: str) -> str: def _generate_lib_artifact_name(name: str, _: Triple) -> str:
"""Return the Windows import-library file name for ``name``. """Return the Windows import-library file name for ``name``.
Only meaningful on Windows; returned regardless of platform for use by the Only meaningful on Windows; returned regardless of platform for use by the
@@ -138,18 +165,29 @@ def _generate_lib_artifact_name(name: str) -> str:
def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]: def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
"""Yield :class:`FileCopyInfo` for the built dynamic library (and, on Windows, the import library).""" """Yield :class:`FileCopyInfo` for the built dynamic library (and, on Windows, the import library)."""
extractor = ctx.extractor extractor = ctx.extractor
target_directory = extractor.get_target_directory() / "release"
target_name = extractor.get_target_name() 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 # copy artifact for redist
dll_artifact_filename = _generate_dll_artifact_name(target_name) dll_artifact_filename = _generate_dll_artifact_name(target_name, target_triple)
yield FileCopyInfo( yield FileCopyInfo(
target_directory / dll_artifact_filename, target_directory / dll_artifact_filename,
Path("bin" if sys.platform == "win32" else "lib") / dll_artifact_filename, Path("bin" if _is_windows_env(target_triple) else "lib")
/ dll_artifact_filename,
) )
# copy artifact for linking only on windows # copy artifact for linking only on windows
if sys.platform == "win32": if _is_windows_env(target_triple):
lib_artifact_filename = _generate_lib_artifact_name(target_name) lib_artifact_filename = _generate_lib_artifact_name(target_name, target_triple)
yield FileCopyInfo( yield FileCopyInfo(
target_directory / lib_artifact_filename, target_directory / lib_artifact_filename,
Path("lib") / lib_artifact_filename, Path("lib") / lib_artifact_filename,
@@ -187,6 +225,11 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
metadata = ctx.metadata metadata = ctx.metadata
target_name = extractor.get_target_name() 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 # compute fallback name
cmake_fallback_name = _sanitize_name(target_name) cmake_fallback_name = _sanitize_name(target_name)
# get namespace and target name with fallback # get namespace and target name with fallback
@@ -198,13 +241,22 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
if metadata.cmake.target_name is not None: if metadata.cmake.target_name is not None:
cmake_target_name = metadata.cmake.target_name 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 # build cmake properties and render
properties = CMakeProperties( properties = CMakeProperties(
cmake_namespace_name, cmake_namespace_name,
cmake_target_name, cmake_target_name,
_generate_dll_artifact_name(target_name), _generate_dll_artifact_name(target_name, target_triple),
_generate_lib_artifact_name(target_name), _generate_lib_artifact_name(target_name, target_triple),
extractor.get_version(), extractor.get_version(),
cmake_dependencies,
) )
render = CMakeRender(properties) render = CMakeRender(properties)
# return infos # return infos
@@ -248,8 +300,16 @@ def resolve_pkgconfig_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
if pkgconfig_description is None: if pkgconfig_description is None:
pkgconfig_description = "" 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( properties = PkgConfigProperties(
pkgconfig_name, pkgconfig_description, target_name, extractor.get_version() pkgconfig_name,
pkgconfig_description,
target_name,
extractor.get_version(),
pkgconfig_requires,
) )
render = PkgConfigRender(properties) render = PkgConfigRender(properties)
yield TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{pkgconfig_id}.pc")) yield TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{pkgconfig_id}.pc"))
+16
View File
@@ -9,6 +9,7 @@ themselves, so the accepted options and their semantics live in one place.
import argparse import argparse
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from .utils import Triple
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -31,6 +32,10 @@ class Cli:
"""Destination of the zip archive bundling the distribution tree, or """Destination of the zip archive bundling the distribution tree, or
``None`` when no archive is requested.""" ``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: def parse() -> Cli:
"""Parse command-line arguments into a :class:`Cli` instance. """Parse command-line arguments into a :class:`Cli` instance.
@@ -77,9 +82,20 @@ def parse() -> Cli:
help="Path of the zip archive created from the dist-dir contents", help="Path of the zip archive created from the dist-dir contents",
metavar="ZIP", 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() args = parser.parse_args()
return Cli( return Cli(
manifest=args.manifest, manifest=args.manifest,
dist_dir=args.dist_dir, dist_dir=args.dist_dir,
dist_zip=args.dist_zip, dist_zip=args.dist_zip,
target=args.target,
) )
+117 -19
View File
@@ -14,10 +14,18 @@ import subprocess
from dataclasses import dataclass from dataclasses import dataclass
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from re import Pattern, compile
from typing import Any, Callable from typing import Any, Callable
from semver import Version from semver import Version
from . import utils from . import utils
from .utils import dict_chain_get, dict_typed_get, dict_typed_get_required, VERSION from .utils import (
Triple,
VERSION,
dict_chain_get,
dict_typed_get,
dict_typed_get_required,
list_typed_iter,
)
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -47,6 +55,35 @@ class MetadataHeader:
) )
@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) @dataclass(frozen=True)
class MetadataCMake: class MetadataCMake:
"""Optional ``[package.metadata.omrf.cmake]`` section.""" """Optional ``[package.metadata.omrf.cmake]`` section."""
@@ -55,6 +92,8 @@ class MetadataCMake:
"""CMake target namespace, or ``None`` to fall back to the project name.""" """CMake target namespace, or ``None`` to fall back to the project name."""
target_name: str | None target_name: str | None
"""CMake target name, or ``None`` to fall back to the project name.""" """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: def __post_init__(self) -> None:
"""Validate the namespace and target names when specified. """Validate the namespace and target names when specified.
@@ -71,9 +110,20 @@ class MetadataCMake:
@staticmethod @staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataCMake": def from_dict(d: dict[str, Any]) -> "MetadataCMake":
"""Build a :class:`MetadataCMake` from its raw TOML table.""" """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( return MetadataCMake(
namespace_name=dict_typed_get(d, "namespace_name", str), namespace_name=dict_typed_get(d, "namespace_name", str),
target_name=dict_typed_get(d, "target_name", str), target_name=dict_typed_get(d, "target_name", str),
dependencies=dependencies,
) )
@@ -87,6 +137,8 @@ class MetadataPkgConfig:
"""Human-readable package name, or ``None`` to fall back to the project name.""" """Human-readable package name, or ``None`` to fall back to the project name."""
description: str | None description: str | None
"""Brief package description, or ``None`` to fall back to the project description.""" """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: def __post_init__(self) -> None:
"""Validate the id, name and description when specified. """Validate the id, name and description when specified.
@@ -102,14 +154,26 @@ class MetadataPkgConfig:
self.description self.description
): ):
raise ValueError("bad pkg-config description (has EOL chars)") 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 @staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig": def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig":
"""Build a :class:`MetadataPkgConfig` from its raw TOML table.""" """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( return MetadataPkgConfig(
id=dict_typed_get(d, "id", str), id=dict_typed_get(d, "id", str),
name=dict_typed_get(d, "name", str), name=dict_typed_get(d, "name", str),
description=dict_typed_get(d, "description", str), description=dict_typed_get(d, "description", str),
requires=requires,
) )
@@ -148,13 +212,12 @@ class Metadata:
else: else:
min_version = None min_version = None
raw_headers = dict_typed_get_required(d, "headers", list) headers = tuple(
headers_list: list[MetadataHeader] = [] MetadataHeader.from_dict(item)
for i, item in enumerate(raw_headers): for item in list_typed_iter(
if not isinstance(item, dict): dict_typed_get_required(d, "headers", list), dict
raise TypeError(f"headers[{i}] must be a table") )
headers_list.append(MetadataHeader.from_dict(item)) )
headers = tuple(headers_list)
raw_cmake = dict_typed_get(d, "cmake", dict) raw_cmake = dict_typed_get(d, "cmake", dict)
cmake = MetadataCMake.from_dict(raw_cmake) if raw_cmake is not None else None cmake = MetadataCMake.from_dict(raw_cmake) if raw_cmake is not None else None
@@ -192,6 +255,13 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
return wrapper 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: class MetadataExtractor:
"""Access layer over ``cargo metadata`` and the OMRF metadata table. """Access layer over ``cargo metadata`` and the OMRF metadata table.
@@ -208,6 +278,8 @@ class MetadataExtractor:
"""The package item in cargo metadata's packages list pointing to user request package""" """The package item in cargo metadata's packages list pointing to user request package"""
__metadata_target: dict[str, Any] __metadata_target: dict[str, Any]
"""The target item in package item's targets list pointing to the main target""" """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: def __init__(self, cargo_toml_path: Path) -> None:
"""Run ``cargo metadata`` and locate the package and main target. """Run ``cargo metadata`` and locate the package and main target.
@@ -221,6 +293,34 @@ class MetadataExtractor:
self.__metadata_target = MetadataExtractor.__extract_metadata_target( self.__metadata_target = MetadataExtractor.__extract_metadata_target(
self.__metadata_package, cargo_toml_path 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 @staticmethod
@wrap_metadata_errors @wrap_metadata_errors
@@ -243,7 +343,7 @@ class MetadataExtractor:
] ]
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE) proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
try: try:
stdout, stderr = proc.communicate(timeout=10) stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT_SEC)
except subprocess.TimeoutExpired: except subprocess.TimeoutExpired:
proc.kill() proc.kill()
proc.communicate() proc.communicate()
@@ -268,11 +368,8 @@ class MetadataExtractor:
:returns: The matching package item. :returns: The matching package item.
:raises RuntimeError: if no matching package is found. :raises RuntimeError: if no matching package is found.
""" """
packages: list[Any] = dict_typed_get_required(cargo_metadata, "packages", list) packages = dict_typed_get_required(cargo_metadata, "packages", list)
for i, package in enumerate(packages): for package in list_typed_iter(packages, dict):
if not isinstance(package, dict):
raise TypeError(f"packages[{i}] must be a table")
raw_manifest_path = dict_typed_get_required(package, "manifest_path", str) raw_manifest_path = dict_typed_get_required(package, "manifest_path", str)
manifest_path = Path(raw_manifest_path) manifest_path = Path(raw_manifest_path)
if manifest_path == cargo_toml_path: if manifest_path == cargo_toml_path:
@@ -294,11 +391,8 @@ class MetadataExtractor:
# build the path to lib.rs for comparing # build the path to lib.rs for comparing
librs = cargo_toml_path.parent / "src" / "lib.rs" librs = cargo_toml_path.parent / "src" / "lib.rs"
# start checking # start checking
targets: list[Any] = dict_typed_get_required(cargo_package, "targets", list) targets = dict_typed_get_required(cargo_package, "targets", list)
for i, target in enumerate(targets): for target in list_typed_iter(targets, dict):
if not isinstance(target, dict):
raise TypeError(f"targets[{i}] must be a table")
raw_src_path = dict_typed_get_required(target, "src_path", str) raw_src_path = dict_typed_get_required(target, "src_path", str)
src_path = Path(raw_src_path) src_path = Path(raw_src_path)
if src_path == librs: if src_path == librs:
@@ -349,3 +443,7 @@ class MetadataExtractor:
def get_target_name(self) -> str: def get_target_name(self) -> str:
"""Return the name of the main library target.""" """Return the name of the main library target."""
return dict_typed_get_required(self.__metadata_target, "name", str) 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
@@ -14,6 +14,27 @@ from . import common
from .. import utils 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) @dataclass(frozen=True)
class CMakeProperties: class CMakeProperties:
"""Inputs required to render the CMake package-configuration files. """Inputs required to render the CMake package-configuration files.
@@ -44,13 +65,16 @@ class CMakeProperties:
version: Version version: Version
"""Version of the library. Must not carry a prerelease or build component.""" """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: def __post_init__(self) -> None:
"""Validate the fields after construction. """Validate the fields after construction.
:raises ValueError: if the namespace or target name contains characters :raises ValueError: if the namespace or target name contains characters
outside the CMake name pattern, an artifact file name is empty, or outside the CMake name pattern, an artifact file name is empty, or the
the version carries a prerelease or build component (unsupported by version carries a prerelease or build component (unsupported by
CMake config-version files). CMake config-version files).
""" """
if not utils.is_good_name(self.namespace_name): if not utils.is_good_name(self.namespace_name):
@@ -99,6 +123,7 @@ class CMakeRender:
"target": self.__properties.target_name, "target": self.__properties.target_name,
"artifact_dll": self.__properties.artifact_dll, "artifact_dll": self.__properties.artifact_dll,
"artifact_lib": self.__properties.artifact_lib, "artifact_lib": self.__properties.artifact_lib,
"dependencies": self.__properties.dependencies,
} }
return self.__render.render("XXXConfig.cmake.jinja", payload) return self.__render.render("XXXConfig.cmake.jinja", payload)
@@ -42,13 +42,14 @@ class PkgConfigProperties:
version: Version version: Version
"""Version of the library. Must not carry a prerelease or build component.""" """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: def __post_init__(self) -> None:
"""Validate the fields after construction. """Validate the fields after construction.
:raises ValueError: if the name or description is blank, or the :raises ValueError: if the name or description is blank, or the version
version carries a prerelease or build component (unsupported in carries a prerelease or build component (unsupported in pkg-config).
pkg-config).
""" """
if self.name == "" or not utils.is_good_sentence(self.name): if self.name == "" or not utils.is_good_sentence(self.name):
raise ValueError("bad name (blank or has EOL chars) of pkg-config") raise ValueError("bad name (blank or has EOL chars) of pkg-config")
@@ -88,6 +89,8 @@ class PkgConfigRender:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"name": self.__properties.name, "name": self.__properties.name,
"description": self.__properties.description, "description": self.__properties.description,
"version": str(self.__properties.version),
"artifact": self.__properties.artifact, "artifact": self.__properties.artifact,
"requires": self.__properties.requires,
} }
return self.__render.render("XXX.pc.jinja", payload) return self.__render.render("XXX.pc.jinja", payload)
@@ -1,3 +1,5 @@
# 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. Utilize ${pcfiledir} to fetch the directory where current .pc file is.
And back to parent twice to obtain the installation directory. And back to parent twice to obtain the installation directory.
@@ -10,5 +12,8 @@ includedir=${prefix}/include
Name: {{ name }} Name: {{ name }}
Description: {{ description }} Description: {{ description }}
Version: {{ version }} Version: {{ version }}
{%- if requires | length != 0 %}
Requires: {{ requires | join(", ") }}
{%- endif %}
Libs: -L${libdir} -l{{ artifact }} Libs: -L${libdir} -l{{ artifact }}
Cflags: -I${includedir} Cflags: -I${includedir}
@@ -1,3 +1,5 @@
# This file is created by sarasacw-omrf-packer and should not be changed manually.
{# Check whether this target is already exported -#} {# Check whether this target is already exported -#}
if(TARGET {{ namespace }}::{{ target }}) if(TARGET {{ namespace }}::{{ target }})
message(STATUS "Target {{ namespace }}::{{ target }} is already defined.") message(STATUS "Target {{ namespace }}::{{ target }} is already defined.")
@@ -16,21 +18,45 @@ set(MyRust_LIBRARY "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}")
set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include") set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include")
{# Create modern CMake target (IMPORTED target) -#} {# Create modern CMake target (IMPORTED target) -#}
add_library({{ namespace }}::{{ target }} SHARED IMPORTED) add_library({{ target }} SHARED IMPORTED)
set_target_properties({{ namespace }}::{{ target }} PROPERTIES add_library({{ namespace }}::{{ target }} ALIAS {{ target }})
set_target_properties({{ target }} PROPERTIES
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include" INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
) )
{# Handle Windows and UNIX-like respectively -#} {# Handle Windows and UNIX-like respectively -#}
if(WIN32) if(WIN32)
set_target_properties({{ namespace }}::{{ target }} PROPERTIES set_target_properties({{ target }} PROPERTIES
IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}" IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}"
IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}" IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}"
) )
else() else()
set_target_properties({{ namespace }}::{{ target }} PROPERTIES set_target_properties({{ target }} PROPERTIES
IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}" IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}"
) )
endif() endif()
{# Cleanup temporary variables -#} {# Cleanup temporary variables -#}
set(_IMPORT_PREFIX) 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 }})
+58 -1
View File
@@ -4,9 +4,10 @@ Provides version parsing/checking, typed dictionary access helpers, name
validation and template-directory resolution. validation and template-directory resolution.
""" """
from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from re import Pattern, compile from re import Pattern, compile
from typing import Any, overload from typing import Any, Iterator, overload
from semver import Version from semver import Version
VERSION: Version = Version(1, 0, 0) VERSION: Version = Version(1, 0, 0)
@@ -105,6 +106,20 @@ def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]:
return d 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_+-]+") _NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_+-]+")
@@ -129,3 +144,45 @@ def is_good_sentence(s: str) -> bool:
:returns: True if given string don't contain any EOL chars, otherwise false. :returns: True if given string don't contain any EOL chars, otherwise false.
""" """
return _EOL_PATTERN.search(s) is None 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,
)