Compare commits

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