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