Compare commits

...
17 Commits
Author SHA1 Message Date
yyc12345 44adb10989 feat: update library lifecycle return value 2026-08-10 22:17:19 +08:00
yyc12345 65e825f8db feat: add library lifecycle utility in omrf 2026-08-10 19:35:10 +08:00
yyc12345 8eb24a0b71 doc: update design 2026-08-09 22:12:10 +08:00
yyc12345 0c21166e53 refactor: refactor c-friendly ffi design doc 2026-08-09 21:41:08 +08:00
yyc12345 bad72120f9 fix: fix cmake template of packer 2026-08-07 10:27:47 +08:00
yyc12345 0141c461da doc: writing design doc 2026-08-06 20:22:17 +08:00
yyc12345 eb6453362d feat: add note in generated package manager files 2026-08-06 14:11:35 +08:00
yyc12345 d5c99be4d8 doc: add developer notes 2026-08-06 14:07:54 +08:00
yyc12345 d769af9efe fix: add lost author field for cargo toml 2026-08-06 14:07:45 +08:00
yyc12345 f9333c72be chore: update packer metadata and readme 2026-08-06 13:47:05 +08:00
yyc12345 bf0837e4a6 fix: fix packer template error 2026-08-06 13:19:59 +08:00
yyc12345 0b4ecccb71 refactor: improve metadata module 2026-08-06 13:05:18 +08:00
yyc12345 ccf19ac0ab feat: support public/interface dependencies in packer 2026-08-06 12:48:41 +08:00
yyc12345 7527d3763f fix: fix lib artifact path issue 2026-08-06 09:36:38 +08:00
yyc12345 dbec1b4fca feat: use rust triple instead of python sys module
after this change, cross compilation are supported in theory.
2026-08-05 20:37:41 +08:00
yyc12345 520bfc2054 doc: add docstring and do some refactor 2026-08-05 15:53:52 +08:00
yyc12345 816e3a59d0 refactor: move version checker to metadata 2026-08-05 15:33:02 +08:00
23 changed files with 1360 additions and 87 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"
+42 -18
View File
@@ -1,7 +1,14 @@
"""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 logging
import sys import sys
from pathlib import Path from pathlib import Path
from .utils import VERSION
from .cli import Cli, parse as parse_cli from .cli import Cli, parse as parse_cli
from .artifact import ( from .artifact import (
ArtifactContext, ArtifactContext,
@@ -15,30 +22,34 @@ from .metadata import MetadataExtractor
class App: 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 __opts: Cli
__extractor: MetadataExtractor __extractor: MetadataExtractor
__ctx: ArtifactContext __ctx: ArtifactContext
def __init__(self, opts: Cli) -> None: def __init__(self, opts: Cli) -> None:
"""Build the extractor and artifact context from CLI options.
:param opts: Parsed command-line options.
"""
# assign cli options # assign cli options
self.__opts = opts self.__opts = opts
# initialize packer # build essential instances
try: self.__extractor = MetadataExtractor(self.__opts.manifest)
# build essential instances self.__ctx = ArtifactContext(self.__opts, self.__extractor)
self.__extractor = MetadataExtractor(self.__opts.manifest)
self.__ctx = ArtifactContext(self.__extractor)
# check version
metadata = self.__ctx.metadata
if metadata.min_version is not None:
if metadata.min_version > VERSION:
raise RuntimeError(
f"requested minimum version is not fulfilled. {metadata.min_version} required got {VERSION}"
)
except Exception as e:
logging.error(f"fail to initialize packer: %s", e)
sys.exit(1)
def run(self) -> None: 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 # create distribution
with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver: with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver:
# create basic directory # create basic directory
@@ -66,10 +77,23 @@ class App:
def main() -> None: 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 # parse command line arguments
opts = parse_cli() opts = parse_cli()
# setup logging # setup logging
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO) logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
app = App(opts) # initialize packer and run
app.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)
@@ -1,3 +1,9 @@
"""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 zipfile
import shutil import shutil
from pathlib import Path from pathlib import Path
@@ -43,6 +49,11 @@ class Archiver:
self.__dist_zip = None self.__dist_zip = None
def push_dir(self, arcname: Path) -> 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: if self.__dist_dir is not None:
target_filepath = self.__dist_dir / arcname target_filepath = self.__dist_dir / arcname
target_filepath.parent.mkdir(parents=True, exist_ok=True) target_filepath.parent.mkdir(parents=True, exist_ok=True)
+112 -17
View File
@@ -1,30 +1,51 @@
import sys """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 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`.
: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`."""
return self.__extractor return self.__extractor
@property @property
def metadata(self) -> Metadata: def metadata(self) -> Metadata:
"""The cached :class:`Metadata` read from the extractor."""
return self.__metadata return self.__metadata
@@ -61,6 +82,8 @@ def _extract_common_prefix(pattern: str) -> tuple[str, str]:
@dataclass(frozen=True) @dataclass(frozen=True)
class FileCopyInfo: class FileCopyInfo:
"""A single on-disk file to copy into the distribution."""
from_path: Path from_path: Path
"""The absolute path pointing to source file without any wildcard""" """The absolute path pointing to source file without any wildcard"""
to_path: Path to_path: Path
@@ -68,6 +91,11 @@ class FileCopyInfo:
def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]: 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 extractor = ctx.extractor
metadata = ctx.metadata metadata = ctx.metadata
project_root = Path(extractor.get_project_dir()) project_root = Path(extractor.get_project_dir())
@@ -92,37 +120,74 @@ def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
) )
def _generate_dll_artifact_name(name: str) -> str: _WINDOWS_ENV_IDENTS: tuple[str, ...] = ("windows", "cygwin")
match sys.platform:
case "win32" | "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" 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``.
Only meaningful on Windows; returned regardless of platform for use by the
CMake properties.
"""
# only Windows has this feature, so we simply return it # only Windows has this feature, so we simply return it
return f"{name}.dll.lib" return f"{name}.dll.lib"
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)."""
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,
@@ -131,6 +196,8 @@ def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
@dataclass(frozen=True) @dataclass(frozen=True)
class TextCopyInfo: class TextCopyInfo:
"""A piece of generated text to write into the distribution."""
text: str text: str
"""The content of source file""" """The content of source file"""
to_path: Path to_path: Path
@@ -141,6 +208,10 @@ _BAD_NAME_PATTERN: Pattern = compile(r"[^a-zA-Z0-9_+-]")
def _sanitize_name(name: str) -> str: 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) new_name = _BAD_NAME_PATTERN.sub("", name)
if new_name == "": if new_name == "":
raise ValueError("name is blank after sanitizing") raise ValueError("name is blank after sanitizing")
@@ -149,10 +220,16 @@ def _sanitize_name(name: str) -> str:
def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]: def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
"""Yield :class:`TextCopyInfo` for the generated CMake ``Config`` and ``ConfigVersion`` files."""
extractor = ctx.extractor extractor = ctx.extractor
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
@@ -164,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
@@ -195,6 +281,7 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
def resolve_pkgconfig_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]: def resolve_pkgconfig_copy(ctx: ArtifactContext) -> Iterator[TextCopyInfo]:
"""Yield :class:`TextCopyInfo` for the generated pkg-config ``.pc`` file."""
extractor = ctx.extractor extractor = ctx.extractor
metadata = ctx.metadata metadata = ctx.metadata
target_name = extractor.get_target_name() target_name = extractor.get_target_name()
@@ -213,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"))
+24
View File
@@ -1,6 +1,15 @@
"""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 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)
@@ -23,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.
@@ -69,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,
) )
+242 -19
View File
@@ -1,82 +1,223 @@
"""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 json
import os import os
import subprocess 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 from .utils import (
Triple,
VERSION,
dict_chain_get,
dict_typed_get,
dict_typed_get_required,
list_typed_iter,
)
@dataclass(frozen=True) @dataclass(frozen=True)
class MetadataHeader: 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 from_path: str
"""Source path relative to the project root; may be a UNIX-style glob."""
to_path: str to_path: str
"""Destination path relative to the ``include`` directory (``""`` means the root)."""
def __post_init__(self) -> None: def __post_init__(self) -> None:
"""Validate that ``from_path`` is non-empty."""
if self.from_path == "": if self.from_path == "":
raise ValueError("header 'from' must not be empty") raise ValueError("header 'from' must not be empty")
@staticmethod @staticmethod
def from_dict(d: dict[str, Any]) -> "MetadataHeader": def from_dict(d: dict[str, Any]) -> "MetadataHeader":
"""Build a :class:`MetadataHeader` from its raw TOML table."""
return MetadataHeader( return MetadataHeader(
from_path=dict_typed_get_required(d, "from", str), from_path=dict_typed_get_required(d, "from", str),
to_path=dict_typed_get(d, "to", 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) @dataclass(frozen=True)
class MetadataCMake: class MetadataCMake:
"""Optional ``[package.metadata.omrf.cmake]`` section."""
namespace_name: str | None namespace_name: str | None
"""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."""
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 @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."""
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,
) )
@dataclass(frozen=True) @dataclass(frozen=True)
class MetadataPkgConfig: class MetadataPkgConfig:
"""Optional ``[package.metadata.omrf.pkgconfig]`` section."""
id: str | None id: str | None
"""pkg-config package id (the ``.pc`` file stem), or ``None`` to fall back to the project name."""
name: str | None name: str | None
"""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."""
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 @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."""
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,
) )
@dataclass(frozen=True) @dataclass(frozen=True)
class Metadata: class Metadata:
"""Typed view of the whole ``[package.metadata.omrf]`` table."""
min_version: Version | None min_version: Version | None
"""Minimum packer version required by the project, or ``None`` for no constraint."""
headers: tuple[MetadataHeader, ...] headers: tuple[MetadataHeader, ...]
"""Header assets to distribute (required, possibly empty)."""
cmake: MetadataCMake | None cmake: MetadataCMake | None
"""CMake override section, or ``None`` when absent."""
pkgconfig: MetadataPkgConfig | None 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 @staticmethod
def from_dict(d: dict[str, Any]) -> "Metadata": 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) raw_min_version = dict_typed_get(d, "min_version", str)
if raw_min_version is not None: if raw_min_version is not None:
min_version = utils.parse_version(raw_min_version) min_version = utils.parse_version(raw_min_version)
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
@@ -97,6 +238,13 @@ class Metadata:
def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]: 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) @wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R: def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
try: try:
@@ -107,15 +255,37 @@ 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.
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] __metadata: dict[str, Any]
"""The direct output of ``cargo metadata``""" """The direct output of ``cargo metadata``"""
__metadata_package: dict[str, Any] __metadata_package: dict[str, Any]
"""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.
:param cargo_toml_path: Path to the ``Cargo.toml`` of the project to pack.
"""
self.__metadata = MetadataExtractor.__extract_metadata(cargo_toml_path) self.__metadata = MetadataExtractor.__extract_metadata(cargo_toml_path)
self.__metadata_package = MetadataExtractor.__extract_metadata_package( self.__metadata_package = MetadataExtractor.__extract_metadata_package(
self.__metadata, cargo_toml_path self.__metadata, cargo_toml_path
@@ -123,10 +293,44 @@ 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
def __extract_metadata(cargo_toml_path: Path) -> dict[str, Any]: 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") cargo_bin = os.getenv("OMRF_PACKER_CARGO", "cargo")
cmd = [ cmd = [
cargo_bin, cargo_bin,
@@ -139,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()
@@ -157,11 +361,15 @@ class MetadataExtractor:
def __extract_metadata_package( def __extract_metadata_package(
cargo_metadata: dict[str, Any], cargo_toml_path: Path cargo_metadata: dict[str, Any], cargo_toml_path: Path
) -> dict[str, Any]: ) -> dict[str, Any]:
packages: list[Any] = dict_typed_get_required(cargo_metadata, "packages", list) """Find the package whose ``manifest_path`` matches the given manifest.
for i, package in enumerate(packages):
if not isinstance(package, dict):
raise TypeError(f"packages[{i}] must be a table")
: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) 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:
@@ -173,14 +381,18 @@ class MetadataExtractor:
def __extract_metadata_target( def __extract_metadata_target(
cargo_package: dict[str, Any], cargo_toml_path: Path cargo_package: dict[str, Any], cargo_toml_path: Path
) -> dict[str, Any]: ) -> 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 # 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:
@@ -192,19 +404,24 @@ class MetadataExtractor:
@wrap_metadata_errors @wrap_metadata_errors
def get_target_directory(self) -> Path: def get_target_directory(self) -> Path:
"""Get the absolute path to directory where Rust target directory is""" """Get the absolute path to directory where Rust target directory is"""
raw_target_directory = dict_typed_get_required(self.__metadata, "target_directory", str) raw_target_directory = dict_typed_get_required(
self.__metadata, "target_directory", str
)
return Path(raw_target_directory) return Path(raw_target_directory)
@wrap_metadata_errors @wrap_metadata_errors
def get_name(self) -> str: def get_name(self) -> str:
"""Return the package name as reported by cargo."""
return dict_typed_get_required(self.__metadata_package, "name", str) return dict_typed_get_required(self.__metadata_package, "name", str)
@wrap_metadata_errors @wrap_metadata_errors
def get_description(self) -> str | None: def get_description(self) -> str | None:
"""Return the package description, or ``None`` if unset."""
return dict_typed_get(self.__metadata_package, "description", str) return dict_typed_get(self.__metadata_package, "description", str)
@wrap_metadata_errors @wrap_metadata_errors
def get_version(self) -> Version: 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) raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
return utils.parse_version(raw_version) return utils.parse_version(raw_version)
@@ -218,9 +435,15 @@ class MetadataExtractor:
@wrap_metadata_errors @wrap_metadata_errors
def get_metadata(self) -> Metadata: def get_metadata(self) -> Metadata:
"""Return the parsed :class:`Metadata` model from the OMRF table."""
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf") omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
return Metadata.from_dict(omrf) return Metadata.from_dict(omrf)
@wrap_metadata_errors @wrap_metadata_errors
def get_target_name(self) -> str: def get_target_name(self) -> str:
"""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)
@@ -9,25 +9,11 @@ rendering on top of :class:`renders.common.BaseRender`.
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from re import Pattern, compile
from . import common from . import common
from .. import utils from .. import utils
from semver import Version from semver import Version
_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) @dataclass(frozen=True)
class PkgConfigProperties: class PkgConfigProperties:
"""Inputs required to render the pkg-config ``.pc`` file. """Inputs required to render the pkg-config ``.pc`` file.
@@ -56,17 +42,18 @@ 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 _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")
if self.description == "" or not _is_good_sentence(self.description): if self.description == "" or not utils.is_good_sentence(self.description):
raise ValueError("bad description (blank or has EOL chars) of pkg-config") raise ValueError("bad description (blank or has EOL chars) of pkg-config")
if not utils.is_good_name(self.artifact): if not utils.is_good_name(self.artifact):
raise ValueError("bad artifact name in pkg-config") raise ValueError("bad artifact name in pkg-config")
@@ -102,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 }})
+121 -3
View File
@@ -1,6 +1,13 @@
"""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 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)
@@ -15,6 +22,7 @@ def parse_version(vs: str) -> Version:
else: else:
return v return v
def is_good_version(v: Version) -> bool: def is_good_version(v: Version) -> bool:
"""Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package. """Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package.
@@ -22,19 +30,39 @@ def is_good_version(v: Version) -> bool:
""" """
return v.prerelease is None and v.build is None return v.prerelease is None and v.build is None
def get_root_dir() -> Path: def get_root_dir() -> Path:
"""Return the resolved directory that contains this package."""
return Path(__file__).resolve().parent return Path(__file__).resolve().parent
def get_templates_dir() -> Path: def get_templates_dir() -> Path:
"""Return the directory holding the Jinja2 templates (``<root>/templates``)."""
return get_root_dir() / "templates" return get_root_dir() / "templates"
@overload @overload
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ... def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ...
@overload @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](
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = None) -> Any: 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) tmp = d.get(key, None)
if tmp is None: if tmp is None:
return default return default
@@ -45,6 +73,18 @@ def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = N
def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T: 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) result = dict_typed_get(d, key, ty)
if result is None: if result is None:
raise ValueError(f'can not find key "{key}" in given dictionary') raise ValueError(f'can not find key "{key}" in given dictionary')
@@ -52,14 +92,37 @@ def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]: 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: for arg in args:
d = dict_typed_get_required(d, arg, dict) d = dict_typed_get_required(d, arg, dict)
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_+-]+")
def is_good_name(name: str) -> bool: def is_good_name(name: str) -> bool:
"""This package specific name checker """This package specific name checker
@@ -68,3 +131,58 @@ def is_good_name(name: str) -> bool:
:returns: True if given name is legal, otherwise false. :returns: True if given name is legal, otherwise false.
""" """
return _NAME_PATTERN.fullmatch(name) is not None 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,
)