Compare commits
19
Commits
ae3f10a640
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44adb10989 | ||
|
|
65e825f8db | ||
|
|
8eb24a0b71 | ||
|
|
0c21166e53 | ||
|
|
bad72120f9 | ||
|
|
0141c461da | ||
|
|
eb6453362d | ||
|
|
d5c99be4d8 | ||
|
|
d769af9efe | ||
|
|
f9333c72be | ||
|
|
bf0837e4a6 | ||
|
|
0b4ecccb71 | ||
|
|
ccf19ac0ab | ||
|
|
7527d3763f | ||
|
|
dbec1b4fca | ||
|
|
520bfc2054 | ||
|
|
816e3a59d0 | ||
|
|
a72a685c84 | ||
|
|
c28f08b75d |
+14
-1
@@ -2,4 +2,17 @@
|
||||
|
||||
## Bump Version Up
|
||||
|
||||
TODO
|
||||
### Bump OMRF Version Up
|
||||
|
||||
- Change the version declared in `Cargo.toml`.
|
||||
|
||||
### Bump OMRF Packer Version Up
|
||||
|
||||
- Change the version declared in `pyproject.toml`.
|
||||
- Change the version constant `VERSION` declared in `utils.py`.
|
||||
- Change the minimum required version declared in the metadata example section of `README.md`.
|
||||
|
||||
## Version Tag
|
||||
|
||||
- Use `omrf/x.y.z` to tag the version of `sarasacw-omrf`.
|
||||
- Use `omrf-packer/x.y.z` to tag the version of `sarasacw-omrf-packer`.
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# Allowed Data Types
|
||||
|
||||
Under this section we discuss which data types are allowed to appear in an FFI
|
||||
interface and how to convert Rust data types into these allowed types. The
|
||||
correspondence between these types and their C/C++ counterparts is covered in
|
||||
[C/C++ Headers](c-cpp-headers.md).
|
||||
|
||||
## Primitive Type Conversion
|
||||
|
||||
This subsection covers the conversion of primitive types. The rules described here
|
||||
apply to both parameter and return value positions.
|
||||
|
||||
### Types Allowed to Cross the Boundary Directly
|
||||
|
||||
The following Rust types are allowed to be passed directly across the FFI boundary:
|
||||
|
||||
- `bool`
|
||||
- `f32`
|
||||
- `f64`
|
||||
- `i8`
|
||||
- `i16`
|
||||
- `i32`
|
||||
- `i64`
|
||||
- `u8`
|
||||
- `u16`
|
||||
- `u32`
|
||||
- `u64`
|
||||
- `usize`
|
||||
- `isize`
|
||||
|
||||
### The u128 and i128 Problem
|
||||
|
||||
The `u128` and `i128` types have very poor support in the C/C++ standard libraries.
|
||||
Therefore they cannot be passed directly across the FFI boundary.
|
||||
|
||||
To transfer data of these types, refer to the later section about passing opaque
|
||||
structs.
|
||||
|
||||
### The char Problem
|
||||
|
||||
The Rust `char` type only holds valid Unicode scalar values. The Rust official
|
||||
documentation explicitly states that the `char` type does not have FFI safety, so it
|
||||
cannot be used as a value passed across the FFI boundary.
|
||||
|
||||
The solution is to use the Rust `u32` type for passing. Under this scheme:
|
||||
|
||||
- The function signature uses `u32` as the parameter type.
|
||||
- For input parameters, use `char::try_from` to perform a fallible conversion that
|
||||
validates the incoming character.
|
||||
- For output parameters, use `u32::from` for the conversion.
|
||||
@@ -0,0 +1,85 @@
|
||||
# C/C++ Headers
|
||||
|
||||
Under this section we discuss how to write the C/C++ header files that accompany the
|
||||
Rust FFI library. The Rust-side counterpart of each topic is covered in
|
||||
[FFI Function Signatures](ffi-function-signatures.md) and
|
||||
[Allowed Data Types](allowed-data-types.md).
|
||||
|
||||
## Function Declaration Style
|
||||
|
||||
FFI functions must be exported under C names, that is, they must not go through the
|
||||
C++ name mangling mechanism. In the generated C header file, you need to use the
|
||||
`extern "C"` specifier, the `__cplusplus` macro and macro conditionals to import the
|
||||
declarations correctly, for example:
|
||||
|
||||
```c++
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif // __cplusplus
|
||||
|
||||
CError WFStartup(void);
|
||||
|
||||
// More FFI functions omitted...
|
||||
|
||||
#ifdef __cplusplus
|
||||
} // extern "C"
|
||||
#endif // __cplusplus
|
||||
```
|
||||
|
||||
## Parameter Declarations
|
||||
|
||||
A declaration of an exported FFI function in the C header file generally looks like
|
||||
the following:
|
||||
|
||||
```c++
|
||||
CError FBAdd(
|
||||
OMRF_IN_PARAM_TY(uint32_t, in_a),
|
||||
OMRF_IN_PARAM_TY(uint32_t, in_b),
|
||||
OMRF_OUT_PARAM_TY(uint32_t, out_rv),
|
||||
OMRF_OUT_PARAM_TY(bool, out_overflow));
|
||||
```
|
||||
|
||||
## Required Header Files
|
||||
|
||||
The generated C/C++ header files must include the appropriate header files for the
|
||||
types used in the declarations.
|
||||
|
||||
- In C, the `bool` type is provided by `<stddef.h>`. C++ supports `bool` natively
|
||||
and does not need any header file.
|
||||
- In C, the integer types are provided by `<stdint.h>`. In C++, they are provided
|
||||
by `<cstdint>`.
|
||||
- In C, the `uintptr_t` and `intptr_t` types are provided by `<stdint.h>`. In C++,
|
||||
they are provided by `<cstdint>`.
|
||||
- The floating-point types do not require any header file.
|
||||
|
||||
## Type Correspondences
|
||||
|
||||
The following table shows the correspondence between the Rust types that can cross
|
||||
the FFI boundary directly (listed in [Allowed Data Types](allowed-data-types.md))
|
||||
and their C/C++ counterparts. The left column lists the Rust type and the right
|
||||
column lists the corresponding C/C++ type.
|
||||
|
||||
| Rust | C/C++ |
|
||||
|------|-------|
|
||||
| `bool` | `bool` |
|
||||
| `f32` | `float` |
|
||||
| `f64` | `double` |
|
||||
| `i8` | `int8_t` |
|
||||
| `i16` | `int16_t` |
|
||||
| `i32` | `int32_t` |
|
||||
| `i64` | `int64_t` |
|
||||
| `u8` | `uint8_t` |
|
||||
| `u16` | `uint16_t` |
|
||||
| `u32` | `uint32_t` |
|
||||
| `u64` | `uint64_t` |
|
||||
| `usize` | `uintptr_t` |
|
||||
| `isize` | `intptr_t` |
|
||||
|
||||
## The char Problem
|
||||
|
||||
For the Rust `char` type, the C/C++ header side uses `char32_t` as the parameter
|
||||
type. Note that `char32_t` can hold any 32-bit unsigned integer, which is broader
|
||||
than the set of valid Unicode scalar values carried by the Rust `char` type. The
|
||||
Rust side is responsible for validating the incoming value; see
|
||||
[The char Problem](allowed-data-types.md#the-char-problem) in
|
||||
[Allowed Data Types](allowed-data-types.md).
|
||||
@@ -0,0 +1,169 @@
|
||||
# FFI Function Signatures
|
||||
|
||||
Under this section we discuss the signature requirements of the exported FFI
|
||||
functions.
|
||||
|
||||
## C-style Name Export
|
||||
|
||||
FFI functions must be exported under C names, that is, they must not go through the
|
||||
C++ name mangling mechanism.
|
||||
|
||||
In Rust, you need to decorate the function with the `#[unsafe(no_mangle)]` attribute
|
||||
together with `extern "C"`, for example:
|
||||
|
||||
```rust
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn WFStartup() -> CError {
|
||||
// Function body omitted...
|
||||
}
|
||||
|
||||
// More FFI functions omitted...
|
||||
```
|
||||
|
||||
## Export Name Style
|
||||
|
||||
In this subsection, we discuss the naming style of exported functions.
|
||||
|
||||
In this design, there are exactly two styles to choose from. We call them the
|
||||
Windows style and the POSIX style. Either style can be chosen freely, but within a
|
||||
single library the two styles must not be mixed.
|
||||
|
||||
We recommend the following selection rule: if your library is Windows-only, choose
|
||||
the Windows style; otherwise, choose the POSIX style.
|
||||
|
||||
### Windows Style
|
||||
|
||||
The Windows style format is `<PREFIX><FUNC>` for ordinary functions, and
|
||||
`<PREFIX><STRUCT><FUNC>` for opaque struct functions.
|
||||
|
||||
In the format, `<STRUCT>` is the name of the opaque struct and `<FUNC>` is the name
|
||||
of the function. Both of them must use PascalCase naming.
|
||||
|
||||
For `<PREFIX>`, it is a prefix identifier that is specific to this library. As we
|
||||
all know, C has no namespace concept, and all functions are exported into the same
|
||||
space. Therefore, avoiding name collisions between functions is an important task.
|
||||
By prepending `<PREFIX>` to the function name, name duplication can be avoided as
|
||||
much as possible. For this reason, you need to choose `<PREFIX>` carefully and try
|
||||
to pick a prefix that is unlikely to collide.
|
||||
|
||||
In the Windows style, there are two spellings for `<PREFIX>`. One is the
|
||||
all-uppercase style and the other is the style in which only the first letter is
|
||||
uppercase and all remaining letters are lowercase. Choose according to your own
|
||||
needs. In addition, the length of `<PREFIX>` must not exceed 5 characters, and
|
||||
it must not contain anything other than letters and digits (that is, underscores are
|
||||
not allowed).
|
||||
|
||||
The following examples show some legal and illegal Windows style function names:
|
||||
|
||||
| Name | Verdict | Reason |
|
||||
|------|---------|--------|
|
||||
| `WFStartup` | Legal | An ordinary (non opaque struct) function. |
|
||||
| `WFIconGetHandle` | Legal | An opaque struct function whose `<STRUCT>` is `Icon`. |
|
||||
| `WFWordHandleGetHandle` | Legal | An opaque struct function whose `<STRUCT>` is `WordHandle`. |
|
||||
| `FlGetMessage` | Legal | Uses a `<PREFIX>`, which is `Fl`, that capitalizes only its first letter. |
|
||||
| `MyRustGetMessage` | Illegal | `<PREFIX>`, which is `MyRust`, does not keep all remaining letters lowercase. |
|
||||
| `My_RustGetMessage` | Illegal | `<PREFIX>` contains an underscore. |
|
||||
| `IconGetHandle` | Illegal | Missing `<PREFIX>`. |
|
||||
| `QwertyuiopGetData` | Illegal | `<PREFIX>`, which is `Qwertyuiop`, is too long. |
|
||||
| `WFget_message` | Illegal | `<FUNC>`, which is `get_message`, is not PascalCase. |
|
||||
| `WFicon_handleGetMessage` | Illegal | `<STRUCT>`, which is `icon_handle`, is not PascalCase. |
|
||||
|
||||
### POSIX Style
|
||||
|
||||
The POSIX style format is `<PREFIX>_<FUNC>` for ordinary functions, and
|
||||
`<PREFIX>_<STRUCT>_<FUNC>` for opaque struct functions.
|
||||
|
||||
The `<PREFIX>`, `<STRUCT>` and `<FUNC>` components in the format have the same
|
||||
meaning as in the Windows style, but the format is different. `<STRUCT>` and `<FUNC>`
|
||||
must use snake_case naming. `<PREFIX>` must be all lowercase, and it must not contain
|
||||
anything other than letters and digits (that is, underscores are not allowed). Its
|
||||
length and selection requirements are the same as in the Windows style.
|
||||
|
||||
The following examples show some legal and illegal POSIX style function names:
|
||||
|
||||
| Name | Verdict | Reason |
|
||||
|------|---------|--------|
|
||||
| `wf_startup` | Legal | An ordinary (non opaque struct) function. |
|
||||
| `wf_icon_get_handle` | Legal | An opaque struct function whose `<STRUCT>` is `icon`. |
|
||||
| `wf_word_handle_get_handle` | Legal | An opaque struct function whose `<STRUCT>` is `word_handle`. |
|
||||
| `Fl_get_message` | Illegal | `<PREFIX>`, which is `Fl`, is not all lowercase. |
|
||||
| `my_rust_get_message` | Illegal | `<PREFIX>`, which is `my_rust`, contains an underscore. |
|
||||
| `icon_get_handle` | Illegal | Missing `<PREFIX>`. |
|
||||
| `qwertyuiop_get_data` | Illegal | `<PREFIX>`, which is `qwertyuiop`, is too long. |
|
||||
| `wf_GetMessage` | Illegal | `<FUNC>`, which is `GetMessage`, is not snake_case. |
|
||||
| `wf_Icon_get_message` | Illegal | `<STRUCT>`, which is `Icon`, is not snake_case. |
|
||||
|
||||
## Parameters and Return Value
|
||||
|
||||
Under this section we discuss the parameters and return value of the exported
|
||||
functions. A standard FFI function looks like the following on the Rust side:
|
||||
|
||||
```rust
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn FBAdd(
|
||||
in_a: in_param_ty!(u32),
|
||||
in_b: in_param_ty!(u32),
|
||||
out_rv: out_param_ty!(u32),
|
||||
out_overflow: out_param_ty!(bool),
|
||||
) -> CError {
|
||||
// Function body omitted...
|
||||
}
|
||||
```
|
||||
|
||||
As shown in the example, this function has four parameters. The first two are input
|
||||
parameters and the last two are output parameters. The return value of the function
|
||||
is `CError`.
|
||||
|
||||
### Parameter Rules
|
||||
|
||||
To follow the best practice of C for the signature of functions with any number of
|
||||
input parameters and output parameters, we must design the function signature this
|
||||
way. To implement such a function that accepts any number of inputs and outputs, we
|
||||
need to put both the input parameters and the output parameters into the parameter
|
||||
list. All input parameters must precede any output parameter. The return value,
|
||||
`CError`, is used only to indicate whether the function succeeded or failed.
|
||||
|
||||
The naming style of the function parameters (both input and output) must be
|
||||
snake_case and must not start with an underscore.
|
||||
|
||||
#### Input Parameters
|
||||
|
||||
Input parameters are passed directly by value (for example, integers, floats or
|
||||
pointers). In the example, `in_a` and `in_b` are both of type `u32`. You can use
|
||||
the `in_param_ty!` macro provided by the crate to conveniently mark this.
|
||||
|
||||
By convention, input parameters are prefixed with `in_`, but this is not enforced.
|
||||
|
||||
#### Output Parameters
|
||||
|
||||
Output parameters are passed as pointers. In the example, `out_rv` is actually of
|
||||
type `*mut u32` and `out_overflow` is actually of type `*mut bool`. To avoid
|
||||
repeatedly writing these pointer types and to avoid writing them incorrectly, you
|
||||
can use the `out_param_ty!` macro provided by the crate, which is symmetric with
|
||||
`in_param_ty!`.
|
||||
|
||||
At the same time, you can use the `deref_out_param!` macro provided by the crate to
|
||||
conveniently dereference an output parameter for assignment, or directly use the
|
||||
`set_out_param!` macro provided by the crate for assignment. But you usually do not
|
||||
need to do that, because in later chapters you will be guided on how to correctly
|
||||
use `cffi_wrapper!` to maximize the efficiency of writing FFI functions.
|
||||
|
||||
By convention, output parameters are prefixed with `out_`, but this is not enforced.
|
||||
|
||||
### The Return Value
|
||||
|
||||
The return value of an FFI function must be of the `CError` type.
|
||||
|
||||
`CError` is defined in `last_error::CError` as a plain `u32`. It is not an enum with
|
||||
a fixed set of members. Instead, `CERROR_OK` is the single value that represents
|
||||
absolute, unambiguous success. Every other value's meaning is decided by the
|
||||
consuming project: it may denote a failure, or, like some Win32 functions, a
|
||||
partial success.
|
||||
|
||||
#### Migrating from a bool Return Value
|
||||
|
||||
Projects that previously returned a plain `bool` can adopt the error-code scheme
|
||||
with minimal change: define exactly one non-success code, for example
|
||||
`const CERROR_FAIL: CError = 1;`, and map every error type to it. The result is
|
||||
equivalent to a `bool`: `CERROR_OK` for success and `CERROR_FAIL` for any failure,
|
||||
while leaving room to introduce finer-grained codes later.
|
||||
@@ -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();
|
||||
}
|
||||
+73
-1
@@ -1,5 +1,52 @@
|
||||
# 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.
|
||||
@@ -39,6 +86,20 @@ 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.
|
||||
@@ -50,9 +111,20 @@ 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_CARGO`: Path to the `cargo` executable used to collect project metadata.
|
||||
When unset, the packer invokes `cargo` as resolved from `PATH`.
|
||||
- `OMRF_PACKER_RUSTC`: Path to the `rustc` executable used to resolve the host toolchain triple.
|
||||
When unset, the packer invokes `rustc` as resolved from `PATH`.
|
||||
|
||||
+20
-1
@@ -1,12 +1,26 @@
|
||||
[project]
|
||||
name = "sarasacw-omrf-packer"
|
||||
version = "1.0.0"
|
||||
description = "Add your description here"
|
||||
description = "The packer for Rust, specifically designed for the SarasaCW OMRF project, to distribute C-friendly FFI dynamic library as CMake package."
|
||||
readme = "README.md"
|
||||
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",
|
||||
@@ -15,6 +29,11 @@ dependencies = [
|
||||
[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"
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
"""Command-line entry point of sarasacw-omrf-packer.
|
||||
|
||||
Wires the pipeline together: parse CLI options, build a
|
||||
:class:`MetadataExtractor` wrapped in an :class:`ArtifactContext`, then drive
|
||||
an :class:`Archiver` to copy headers/libraries and emit the CMake/pkg-config
|
||||
files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from .utils import VERSION
|
||||
from .cli import Cli, parse as parse_cli
|
||||
from .artifact import (
|
||||
ArtifactContext,
|
||||
@@ -15,30 +22,34 @@ 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
|
||||
# initialize packer
|
||||
try:
|
||||
# build essential instances
|
||||
self.__extractor = MetadataExtractor(self.__opts.manifest)
|
||||
self.__ctx = ArtifactContext(self.__extractor)
|
||||
# check version
|
||||
metadata = self.__ctx.metadata
|
||||
if metadata.min_version is not None:
|
||||
if metadata.min_version > VERSION:
|
||||
raise RuntimeError(
|
||||
f"requested minimum version is not fulfilled. {metadata.min_version} required got {VERSION}"
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error(f"fail to initialize packer: %s", e)
|
||||
sys.exit(1)
|
||||
# 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
|
||||
@@ -66,10 +77,23 @@ class App:
|
||||
|
||||
|
||||
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)
|
||||
|
||||
app = App(opts)
|
||||
app.run()
|
||||
# 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)
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
"""Dual-sink archiver that materializes a distribution tree.
|
||||
|
||||
The :class:`Archiver` mirrors the same logical entries into an on-disk
|
||||
directory and/or a zip archive, and is usable as a context manager.
|
||||
"""
|
||||
|
||||
import zipfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
@@ -43,12 +49,17 @@ class Archiver:
|
||||
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(str(arcname))
|
||||
self.__dist_zip.mkdir(arcname.as_posix())
|
||||
|
||||
def push_file(self, filepath: Path, arcname: Path) -> None:
|
||||
"""Mirror an existing file into both sinks.
|
||||
@@ -63,7 +74,7 @@ class Archiver:
|
||||
shutil.copy(filepath, target_filepath)
|
||||
|
||||
if self.__dist_zip is not None:
|
||||
self.__dist_zip.write(filepath, arcname)
|
||||
self.__dist_zip.write(filepath, arcname.as_posix())
|
||||
|
||||
def push_text(self, text: str, arcname: Path) -> None:
|
||||
"""Mirror in-memory text into both sinks.
|
||||
@@ -79,7 +90,7 @@ class Archiver:
|
||||
f.write(text)
|
||||
|
||||
if self.__dist_zip is not None:
|
||||
self.__dist_zip.writestr(str(arcname), text)
|
||||
self.__dist_zip.writestr(arcname.as_posix(), text)
|
||||
|
||||
def __enter__(self) -> "Archiver":
|
||||
"""Enter the context and return this archiver as the bound target."""
|
||||
|
||||
@@ -1,29 +1,51 @@
|
||||
import sys
|
||||
"""Resolution of the files and generated texts that make up a distribution.
|
||||
|
||||
Given an :class:`ArtifactContext`, the ``resolve_*_copy`` generators yield the
|
||||
header copies, library copies and generated CMake/pkg-config texts that the
|
||||
archiver then materializes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
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 CMakeProperties, CMakeRender
|
||||
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, extractor: MetadataExtractor) -> None:
|
||||
def __init__(self, opts: Cli, extractor: MetadataExtractor) -> None:
|
||||
"""Wrap an extractor and eagerly fetch its :class:`Metadata`.
|
||||
|
||||
:param extractor: The extractor to wrap and read from.
|
||||
"""
|
||||
self.__opts = opts
|
||||
self.__extractor = extractor
|
||||
self.__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
|
||||
|
||||
|
||||
@@ -60,19 +82,28 @@ def _extract_common_prefix(pattern: str) -> tuple[str, str]:
|
||||
|
||||
@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) -> tuple[FileCopyInfo, ...]:
|
||||
def resolve_include_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
|
||||
"""Yield :class:`FileCopyInfo` for every header declared in the metadata.
|
||||
|
||||
Literal ``from`` paths produce a single entry; glob ``from`` paths are
|
||||
expanded relative to their immutable leading prefix.
|
||||
"""
|
||||
extractor = ctx.extractor
|
||||
metadata = ctx.metadata
|
||||
project_root = Path(extractor.get_project_dir())
|
||||
rv: list[FileCopyInfo] = []
|
||||
|
||||
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)
|
||||
@@ -82,63 +113,91 @@ def resolve_include_copy(ctx: ArtifactContext) -> tuple[FileCopyInfo, ...]:
|
||||
# YYC MARK:
|
||||
# Built ``subpath`` is prefix with ``new_project_root``
|
||||
relative_subpath = subpath.relative_to(new_project_root)
|
||||
rv.append(FileCopyInfo(subpath, to_path / relative_subpath))
|
||||
yield FileCopyInfo(subpath, to_path / relative_subpath)
|
||||
else:
|
||||
rv.append(
|
||||
FileCopyInfo(
|
||||
project_root / header.from_path, to_path / header.from_path
|
||||
)
|
||||
yield FileCopyInfo(
|
||||
project_root / header.from_path, to_path / header.from_path
|
||||
)
|
||||
|
||||
return tuple(rv)
|
||||
|
||||
_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 _generate_dll_artifact_name(name: str) -> str:
|
||||
match sys.platform:
|
||||
case "win32" | "cygwin":
|
||||
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 "linux" | "android" | "freebsd":
|
||||
case x if x in _LINUX_ENV_IDENTS:
|
||||
return f"lib{name}.so"
|
||||
case "darwin" | "ios":
|
||||
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) -> str:
|
||||
def _generate_lib_artifact_name(name: str, _: Triple) -> str:
|
||||
"""Return the Windows import-library file name for ``name``.
|
||||
|
||||
Only meaningful on Windows; returned regardless of platform for use by the
|
||||
CMake properties.
|
||||
"""
|
||||
# only Windows has this feature, so we simply return it
|
||||
return f"{name}.dll.lib"
|
||||
|
||||
|
||||
def resolve_lib_copy(ctx: ArtifactContext) -> tuple[FileCopyInfo, ...]:
|
||||
def resolve_lib_copy(ctx: ArtifactContext) -> Iterator[FileCopyInfo]:
|
||||
"""Yield :class:`FileCopyInfo` for the built dynamic library (and, on Windows, the import library)."""
|
||||
extractor = ctx.extractor
|
||||
target_directory = extractor.get_target_directory() / "release"
|
||||
target_name = extractor.get_target_name()
|
||||
rv: list[FileCopyInfo] = []
|
||||
|
||||
# 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)
|
||||
rv.append(
|
||||
FileCopyInfo(
|
||||
target_directory / dll_artifact_filename,
|
||||
Path("bin" if sys.platform == "win32" else "lib") / dll_artifact_filename,
|
||||
)
|
||||
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 sys.platform == "win32":
|
||||
lib_artifact_filename = _generate_lib_artifact_name(target_name)
|
||||
rv.append(
|
||||
FileCopyInfo(
|
||||
target_directory / lib_artifact_filename,
|
||||
Path("lib") / lib_artifact_filename,
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
return tuple(rv)
|
||||
|
||||
|
||||
@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
|
||||
@@ -149,6 +208,10 @@ _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")
|
||||
@@ -156,11 +219,17 @@ def _sanitize_name(name: str) -> str:
|
||||
return new_name
|
||||
|
||||
|
||||
def resolve_cmake_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
||||
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
|
||||
@@ -172,39 +241,47 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
||||
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),
|
||||
_generate_lib_artifact_name(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
|
||||
return (
|
||||
TextCopyInfo(
|
||||
render.render_config(),
|
||||
Path(
|
||||
"lib",
|
||||
"cmake",
|
||||
cmake_target_name,
|
||||
f"{cmake_target_name}Config.cmake",
|
||||
),
|
||||
yield TextCopyInfo(
|
||||
render.render_config(),
|
||||
Path(
|
||||
"lib",
|
||||
"cmake",
|
||||
cmake_target_name,
|
||||
f"{cmake_target_name}Config.cmake",
|
||||
),
|
||||
TextCopyInfo(
|
||||
render.render_config_version(),
|
||||
Path(
|
||||
"lib",
|
||||
"cmake",
|
||||
cmake_target_name,
|
||||
f"{cmake_target_name}ConfigVersion.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) -> tuple[TextCopyInfo, ...]:
|
||||
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()
|
||||
@@ -223,10 +300,16 @@ def resolve_pkgconfig_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
||||
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_name,
|
||||
pkgconfig_description,
|
||||
target_name,
|
||||
extractor.get_version(),
|
||||
pkgconfig_requires,
|
||||
)
|
||||
render = PkgConfigRender(properties)
|
||||
return (
|
||||
TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{pkgconfig_id}.pc")),
|
||||
)
|
||||
yield TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{pkgconfig_id}.pc"))
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"""Command-line interface for sarasacw-omrf-packer.
|
||||
|
||||
Defines :class:`Cli`, the immutable container of captured options, and
|
||||
:func:`parse`, which interprets ``sys.argv`` via :mod:`argparse`. Downstream
|
||||
stages consume a :class:`Cli` instance rather than reading ``sys.argv``
|
||||
themselves, so the accepted options and their semantics live in one place.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from .utils import Triple
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -23,6 +32,10 @@ class Cli:
|
||||
"""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.
|
||||
@@ -69,9 +82,20 @@ def parse() -> Cli:
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -1,82 +1,223 @@
|
||||
"""Extraction and typed representation of cargo/OMRF metadata.
|
||||
|
||||
Defines the frozen :class:`MetadataHeader`/:class:`MetadataCMake`/
|
||||
:class:`MetadataPkgConfig`/:class:`Metadata` models built from the
|
||||
``[package.metadata.omrf]`` table, and :class:`MetadataExtractor`, which runs
|
||||
``cargo metadata`` and exposes the relevant fields. The
|
||||
:func:`wrap_metadata_errors` decorator gives every raised exception a uniform
|
||||
``error occurs when fetching metadata`` context.
|
||||
"""
|
||||
|
||||
import json
|
||||
import 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 dict_chain_get, dict_typed_get, dict_typed_get_required
|
||||
from .utils import (
|
||||
Triple,
|
||||
VERSION,
|
||||
dict_chain_get,
|
||||
dict_typed_get,
|
||||
dict_typed_get_required,
|
||||
list_typed_iter,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
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
|
||||
|
||||
raw_headers = dict_typed_get_required(d, "headers", list)
|
||||
headers_list: list[MetadataHeader] = []
|
||||
for i, item in enumerate(raw_headers):
|
||||
if not isinstance(item, dict):
|
||||
raise TypeError(f"headers[{i}] must be a table")
|
||||
headers_list.append(MetadataHeader.from_dict(item))
|
||||
headers = tuple(headers_list)
|
||||
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
|
||||
@@ -97,6 +238,13 @@ class Metadata:
|
||||
|
||||
|
||||
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:
|
||||
@@ -107,15 +255,37 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
||||
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
|
||||
@@ -123,10 +293,44 @@ class MetadataExtractor:
|
||||
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,
|
||||
@@ -139,7 +343,7 @@ class MetadataExtractor:
|
||||
]
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=10)
|
||||
stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT_SEC)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.communicate()
|
||||
@@ -157,11 +361,15 @@ class MetadataExtractor:
|
||||
def __extract_metadata_package(
|
||||
cargo_metadata: dict[str, Any], cargo_toml_path: Path
|
||||
) -> dict[str, Any]:
|
||||
packages: list[Any] = dict_typed_get_required(cargo_metadata, "packages", list)
|
||||
for i, package in enumerate(packages):
|
||||
if not isinstance(package, dict):
|
||||
raise TypeError(f"packages[{i}] must be a table")
|
||||
"""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:
|
||||
@@ -173,14 +381,18 @@ class MetadataExtractor:
|
||||
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: list[Any] = dict_typed_get_required(cargo_package, "targets", list)
|
||||
for i, target in enumerate(targets):
|
||||
if not isinstance(target, dict):
|
||||
raise TypeError(f"targets[{i}] must be a table")
|
||||
|
||||
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:
|
||||
@@ -192,19 +404,24 @@ class MetadataExtractor:
|
||||
@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)
|
||||
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)
|
||||
|
||||
@@ -218,9 +435,15 @@ class MetadataExtractor:
|
||||
|
||||
@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
|
||||
|
||||
@@ -14,6 +14,27 @@ 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.
|
||||
@@ -44,13 +65,16 @@ class CMakeProperties:
|
||||
|
||||
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
|
||||
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):
|
||||
@@ -99,6 +123,7 @@ class CMakeRender:
|
||||
"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)
|
||||
|
||||
|
||||
@@ -9,25 +9,11 @@ rendering on top of :class:`renders.common.BaseRender`.
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from re import Pattern, compile
|
||||
from . import common
|
||||
from .. import utils
|
||||
from semver import Version
|
||||
|
||||
|
||||
_EOL_PATTERN: Pattern = compile(r"[\r\n]")
|
||||
|
||||
|
||||
def _is_good_sentence(s: str) -> bool:
|
||||
"""Check whether given string has EOL chars.
|
||||
|
||||
This function is used for checking human-readable name and description,
|
||||
because EOL chars are not allowed in these properties.
|
||||
:returns: True if given string don't contain any EOL chars, otherwise false.
|
||||
"""
|
||||
return _EOL_PATTERN.search(s) is None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PkgConfigProperties:
|
||||
"""Inputs required to render the pkg-config ``.pc`` file.
|
||||
@@ -56,17 +42,18 @@ class PkgConfigProperties:
|
||||
|
||||
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).
|
||||
: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 _is_good_sentence(self.name):
|
||||
if self.name == "" or not utils.is_good_sentence(self.name):
|
||||
raise ValueError("bad name (blank or has EOL chars) of pkg-config")
|
||||
if self.description == "" or not _is_good_sentence(self.description):
|
||||
if self.description == "" or not utils.is_good_sentence(self.description):
|
||||
raise ValueError("bad description (blank or has EOL chars) of pkg-config")
|
||||
if not utils.is_good_name(self.artifact):
|
||||
raise ValueError("bad artifact name in pkg-config")
|
||||
@@ -102,6 +89,8 @@ class PkgConfigRender:
|
||||
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)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# This file is created by sarasacw-omrf-packer and should not be changed manually.
|
||||
|
||||
{#
|
||||
Utilize ${pcfiledir} to fetch the directory where current .pc file is.
|
||||
And back to parent twice to obtain the installation directory.
|
||||
@@ -10,5 +12,8 @@ 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}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# This file is created by sarasacw-omrf-packer and should not be changed manually.
|
||||
|
||||
{# Check whether this target is already exported -#}
|
||||
if(TARGET {{ namespace }}::{{ target }})
|
||||
message(STATUS "Target {{ namespace }}::{{ target }} is already defined.")
|
||||
@@ -16,21 +18,45 @@ set(MyRust_LIBRARY "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}")
|
||||
set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include")
|
||||
|
||||
{# Create modern CMake target (IMPORTED target) -#}
|
||||
add_library({{ namespace }}::{{ target }} SHARED IMPORTED)
|
||||
set_target_properties({{ namespace }}::{{ target }} PROPERTIES
|
||||
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({{ namespace }}::{{ target }} PROPERTIES
|
||||
set_target_properties({{ target }} PROPERTIES
|
||||
IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}"
|
||||
IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}"
|
||||
)
|
||||
else()
|
||||
set_target_properties({{ namespace }}::{{ target }} PROPERTIES
|
||||
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 }})
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"""Shared helpers used across sarasacw-omrf-packer.
|
||||
|
||||
Provides version parsing/checking, typed dictionary access helpers, name
|
||||
validation and template-directory resolution.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from re import Pattern, compile
|
||||
from typing import Any, overload
|
||||
from typing import Any, Iterator, overload
|
||||
from semver import Version
|
||||
|
||||
VERSION: Version = Version(1, 0, 0)
|
||||
@@ -15,6 +22,7 @@ def parse_version(vs: str) -> Version:
|
||||
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.
|
||||
|
||||
@@ -22,19 +30,39 @@ def is_good_version(v: Version) -> bool:
|
||||
"""
|
||||
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:
|
||||
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
|
||||
@@ -45,6 +73,18 @@ def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = N
|
||||
|
||||
|
||||
def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
|
||||
"""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')
|
||||
@@ -52,14 +92,37 @@ def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
|
||||
|
||||
|
||||
def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]:
|
||||
"""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
|
||||
|
||||
@@ -68,3 +131,58 @@ def is_good_name(name: str) -> bool:
|
||||
: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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user