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
|
## 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]
|
[package]
|
||||||
name = "sarasacw-omrf"
|
name = "sarasacw-omrf"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
|
authors = ["yyc12345 <yyc12321@outlook.com>"]
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.85"
|
rust-version = "1.85"
|
||||||
description = "The facilities for Rust, specifically designed for the SarasaCW OMRF project, to support C-friendly FFI."
|
description = "The facilities for Rust, specifically designed for the SarasaCW OMRF project, to support C-friendly FFI."
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
pub mod cffi;
|
pub mod cffi;
|
||||||
pub mod cstr_ffi;
|
pub mod cstr_ffi;
|
||||||
pub mod last_error;
|
pub mod last_error;
|
||||||
|
pub mod library_lifecycle;
|
||||||
pub mod object_pool;
|
pub mod object_pool;
|
||||||
|
|
||||||
#[macro_export]
|
#[macro_export]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
`sarasacw-omrf-packer` is the distribution packer of the Sarasas Chip Workshop Oh My Rust FFI (OMRF) toolset.
|
||||||
|
Given a Rust project that exposes a C-friendly FFI as a `cdylib`, it assembles a CMake-style redistributable tree.
|
||||||
|
It involves header files, the built dynamic library, and generated CMake and pkg-config package files,
|
||||||
|
and it can additionally bundle the result into a zip archive.
|
||||||
|
|
||||||
|
It targets Rust FFI projects that need to be distributed and consumed like ordinary CMake/pkg-config packages,
|
||||||
|
including on MSVC-only Windows toolchains where pkg-config alone is not enough.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Barely works for the specific workflow of Sarasas Chip Workshop.
|
||||||
|
And will be improved by new requirement coming from Sarasas Chip Workshop devlopment.
|
||||||
|
|
||||||
|
Projects declare the minimum required packer version through the `min_version` field in their `[package.metadata.omrf]` table;
|
||||||
|
running an older packer than requested fails fast with a clear error.
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Licensed under the MIT License.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Install from PyPI (the project is built with `uv`, but `pipx install` or `pip install` work just as well on the published wheel):
|
||||||
|
|
||||||
|
```console
|
||||||
|
uv tool install sarasacw-omrf-packer
|
||||||
|
```
|
||||||
|
|
||||||
|
The packer does not build the project itself. Build it first -- `cargo build --release`, adding `--target <triple>` for cross-compilation -- then run:
|
||||||
|
|
||||||
|
```console
|
||||||
|
sarasacw-omrf-packer -m path/to/Cargo.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
`cargo` and `rustc` are resolved from `PATH`; override them with the environment variables listed in the [Environment Variables](#environment-variables) section.
|
||||||
|
|
||||||
|
### Command-line options
|
||||||
|
|
||||||
|
| Option | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `-m`, `--manifest <CARGO.TOML>` | yes | Path to the `Cargo.toml` of the project to pack. |
|
||||||
|
| `-d`, `--dist-dir <DIR>` | no | Directory where the redistributable tree is materialized (`bin/`, `include/`, `lib/`, ...). |
|
||||||
|
| `-z`, `--dist-zip <ZIP>` | no | Path of the zip archive produced from the redistributable tree. |
|
||||||
|
| `-t`, `--target <TRIPLE>` | no | Target triple to pack for (e.g. `x86_64-pc-windows-msvc`). Defaults to the host toolchain triple. |
|
||||||
|
|
||||||
|
`--dist-dir` and `--dist-zip` are independent: pass either, both, or neither. Omitting both performs a dry run that validates the configuration without writing any output. Run `sarasacw-omrf-packer --help` for the authoritative list.
|
||||||
|
|
||||||
## Metadata
|
## Metadata
|
||||||
|
|
||||||
All packer used properties are stored in target Rust manifest file as metadata style.
|
All packer used properties are stored in target Rust manifest file as metadata style.
|
||||||
@@ -39,6 +86,20 @@ namespace_name = "foobar"
|
|||||||
# Target-name part of the generated CMake target.
|
# Target-name part of the generated CMake target.
|
||||||
# This property is optional; it defaults to the name of the target Rust project.
|
# This property is optional; it defaults to the name of the target Rust project.
|
||||||
target_name = "foobar"
|
target_name = "foobar"
|
||||||
|
# Declared CMake dependencies.
|
||||||
|
# Each entry carries a `package` (passed to find_dependency argument) and a `target`
|
||||||
|
# (linked into the generated imported target via INTERFACE_LINK_LIBRARIES).
|
||||||
|
# This property is optional; it defaults to no dependencies.
|
||||||
|
# Because the packer only ever ships dynamic libraries, there are no private or
|
||||||
|
# static dependencies, and no separate field for them.
|
||||||
|
# Declaring dependencies here is rarely necessary and is NOT recommended: it
|
||||||
|
# behaves like a CMake interface/public dependency, and exposing other
|
||||||
|
# libraries' raw types across the FFI boundary raises ABI-compatibility
|
||||||
|
# concerns across runtimes. Use it only when this crate is explicitly a wrapper
|
||||||
|
# around another library.
|
||||||
|
dependencies = [
|
||||||
|
{ package = "ZLIB 1.3.2 REQUIRED", target = "ZLIB::ZLIB" },
|
||||||
|
]
|
||||||
|
|
||||||
[package.metadata.omrf.pkgconfig]
|
[package.metadata.omrf.pkgconfig]
|
||||||
# The unique identifier of the package.
|
# The unique identifier of the package.
|
||||||
@@ -50,9 +111,20 @@ name = "Foo Bar"
|
|||||||
# Brief description of the package.
|
# Brief description of the package.
|
||||||
# This property is optional; it defaults to the description of the target Rust project.
|
# This property is optional; it defaults to the description of the target Rust project.
|
||||||
description = "a brown fox jumps over a lazy dog."
|
description = "a brown fox jumps over a lazy dog."
|
||||||
|
# Declared public pkg-config dependencies.
|
||||||
|
# This property is optional; it defaults to no dependencies.
|
||||||
|
# Because the packer only ever ships dynamic libraries, there are no private
|
||||||
|
# dependencies and Requires.private is not used.
|
||||||
|
# Declaring dependencies here is rarely necessary and is NOT recommended, for
|
||||||
|
# the same ABI-compatibility reasons as the CMake dependencies above. Use it
|
||||||
|
# only when this crate is explicitly a wrapper around another library.
|
||||||
|
requires = ["libfoo >= 1.0", "libbar"]
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
- `OMRF_PACKER_CARGO`: Path to the `cargo` executable used to collect project metadata. When unset, the packer invokes `cargo` as resolved from `PATH`.
|
- `OMRF_PACKER_CARGO`: Path to the `cargo` executable used to collect project metadata.
|
||||||
|
When unset, the packer invokes `cargo` as resolved from `PATH`.
|
||||||
|
- `OMRF_PACKER_RUSTC`: Path to the `rustc` executable used to resolve the host toolchain triple.
|
||||||
|
When unset, the packer invokes `rustc` as resolved from `PATH`.
|
||||||
|
|||||||
+20
-1
@@ -1,12 +1,26 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "sarasacw-omrf-packer"
|
name = "sarasacw-omrf-packer"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
description = "Add your description here"
|
description = "The packer for Rust, specifically designed for the SarasaCW OMRF project, to distribute C-friendly FFI dynamic library as CMake package."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "yyc12345", email = "yyc12321@outlook.com" }
|
{ name = "yyc12345", email = "yyc12321@outlook.com" }
|
||||||
]
|
]
|
||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
|
license = { text = "MIT" }
|
||||||
|
keywords = ["rust", "ffi", "cmake", "pkg-config", "packaging", "omrf"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 5 - Production/Stable",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"Topic :: Software Development :: Build Tools",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Operating System :: Microsoft :: Windows",
|
||||||
|
"Operating System :: POSIX",
|
||||||
|
"Operating System :: MacOS",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"jinja2==3.1.6",
|
"jinja2==3.1.6",
|
||||||
"semver>=3.0.4",
|
"semver>=3.0.4",
|
||||||
@@ -15,6 +29,11 @@ dependencies = [
|
|||||||
[project.scripts]
|
[project.scripts]
|
||||||
sarasacw-omrf-packer = "sarasacw_omrf_packer:main"
|
sarasacw-omrf-packer = "sarasacw_omrf_packer:main"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/SarasasChipWorkshop/sarasacw-omrf"
|
||||||
|
Repository = "https://github.com/SarasasChipWorkshop/sarasacw-omrf"
|
||||||
|
Issues = "https://github.com/SarasasChipWorkshop/sarasacw-omrf/issues"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["uv_build>=0.8.0,<0.9"]
|
requires = ["uv_build>=0.8.0,<0.9"]
|
||||||
build-backend = "uv_build"
|
build-backend = "uv_build"
|
||||||
|
|||||||
@@ -1,7 +1,14 @@
|
|||||||
|
"""Command-line entry point of sarasacw-omrf-packer.
|
||||||
|
|
||||||
|
Wires the pipeline together: parse CLI options, build a
|
||||||
|
:class:`MetadataExtractor` wrapped in an :class:`ArtifactContext`, then drive
|
||||||
|
an :class:`Archiver` to copy headers/libraries and emit the CMake/pkg-config
|
||||||
|
files.
|
||||||
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from .utils import VERSION
|
|
||||||
from .cli import Cli, parse as parse_cli
|
from .cli import Cli, parse as parse_cli
|
||||||
from .artifact import (
|
from .artifact import (
|
||||||
ArtifactContext,
|
ArtifactContext,
|
||||||
@@ -15,30 +22,34 @@ from .metadata import MetadataExtractor
|
|||||||
|
|
||||||
|
|
||||||
class App:
|
class App:
|
||||||
|
"""High-level orchestrator of a single packaging run.
|
||||||
|
|
||||||
|
Holds the shared :class:`MetadataExtractor`/:class:`ArtifactContext` built
|
||||||
|
from the CLI options and exposes :meth:`run` to produce the distribution.
|
||||||
|
"""
|
||||||
|
|
||||||
__opts: Cli
|
__opts: Cli
|
||||||
__extractor: MetadataExtractor
|
__extractor: MetadataExtractor
|
||||||
__ctx: ArtifactContext
|
__ctx: ArtifactContext
|
||||||
|
|
||||||
def __init__(self, opts: Cli) -> None:
|
def __init__(self, opts: Cli) -> None:
|
||||||
|
"""Build the extractor and artifact context from CLI options.
|
||||||
|
|
||||||
|
:param opts: Parsed command-line options.
|
||||||
|
"""
|
||||||
# assign cli options
|
# assign cli options
|
||||||
self.__opts = opts
|
self.__opts = opts
|
||||||
# initialize packer
|
|
||||||
try:
|
|
||||||
# build essential instances
|
# build essential instances
|
||||||
self.__extractor = MetadataExtractor(self.__opts.manifest)
|
self.__extractor = MetadataExtractor(self.__opts.manifest)
|
||||||
self.__ctx = ArtifactContext(self.__extractor)
|
self.__ctx = ArtifactContext(self.__opts, self.__extractor)
|
||||||
# check version
|
|
||||||
metadata = self.__ctx.metadata
|
|
||||||
if metadata.min_version is not None:
|
|
||||||
if metadata.min_version > VERSION:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"requested minimum version is not fulfilled. {metadata.min_version} required got {VERSION}"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logging.error(f"fail to initialize packer: %s", e)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
def run(self) -> None:
|
def run(self) -> None:
|
||||||
|
"""Produce the distribution.
|
||||||
|
|
||||||
|
Opens an :class:`Archiver` over the requested sinks, creates the basic
|
||||||
|
``bin/``/``include/``/``lib/`` layout, then copies headers, libraries
|
||||||
|
and generated package-manager files into it.
|
||||||
|
"""
|
||||||
# create distribution
|
# create distribution
|
||||||
with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver:
|
with Archiver(self.__opts.dist_dir, self.__opts.dist_zip) as archiver:
|
||||||
# create basic directory
|
# create basic directory
|
||||||
@@ -66,10 +77,23 @@ class App:
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Entry point: parse arguments, configure logging and run :class:`App`.
|
||||||
|
|
||||||
|
Logs and exits with status ``1`` on initialization or runtime errors.
|
||||||
|
"""
|
||||||
# parse command line arguments
|
# parse command line arguments
|
||||||
opts = parse_cli()
|
opts = parse_cli()
|
||||||
# setup logging
|
# setup logging
|
||||||
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
|
logging.basicConfig(format="[%(levelname)s] %(message)s", level=logging.INFO)
|
||||||
|
|
||||||
|
# initialize packer and run
|
||||||
|
try:
|
||||||
app = App(opts)
|
app = App(opts)
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("fail to initialize packer: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
try:
|
||||||
app.run()
|
app.run()
|
||||||
|
except Exception as e:
|
||||||
|
logging.error("packer runtime error: %s", e)
|
||||||
|
sys.exit(1)
|
||||||
|
|||||||
@@ -1,3 +1,9 @@
|
|||||||
|
"""Dual-sink archiver that materializes a distribution tree.
|
||||||
|
|
||||||
|
The :class:`Archiver` mirrors the same logical entries into an on-disk
|
||||||
|
directory and/or a zip archive, and is usable as a context manager.
|
||||||
|
"""
|
||||||
|
|
||||||
import zipfile
|
import zipfile
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@@ -43,12 +49,17 @@ class Archiver:
|
|||||||
self.__dist_zip = None
|
self.__dist_zip = None
|
||||||
|
|
||||||
def push_dir(self, arcname: Path) -> None:
|
def push_dir(self, arcname: Path) -> None:
|
||||||
|
"""Create an empty directory entry in both sinks.
|
||||||
|
|
||||||
|
:param arcname: Directory path relative to the distribution root.
|
||||||
|
Parent directories are created as needed for the directory sink.
|
||||||
|
"""
|
||||||
if self.__dist_dir is not None:
|
if self.__dist_dir is not None:
|
||||||
target_filepath = self.__dist_dir / arcname
|
target_filepath = self.__dist_dir / arcname
|
||||||
target_filepath.parent.mkdir(parents=True, exist_ok=True)
|
target_filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
if self.__dist_zip is not None:
|
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:
|
def push_file(self, filepath: Path, arcname: Path) -> None:
|
||||||
"""Mirror an existing file into both sinks.
|
"""Mirror an existing file into both sinks.
|
||||||
@@ -63,7 +74,7 @@ class Archiver:
|
|||||||
shutil.copy(filepath, target_filepath)
|
shutil.copy(filepath, target_filepath)
|
||||||
|
|
||||||
if self.__dist_zip is not None:
|
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:
|
def push_text(self, text: str, arcname: Path) -> None:
|
||||||
"""Mirror in-memory text into both sinks.
|
"""Mirror in-memory text into both sinks.
|
||||||
@@ -79,7 +90,7 @@ class Archiver:
|
|||||||
f.write(text)
|
f.write(text)
|
||||||
|
|
||||||
if self.__dist_zip is not None:
|
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":
|
def __enter__(self) -> "Archiver":
|
||||||
"""Enter the context and return this archiver as the bound target."""
|
"""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
|
import logging
|
||||||
|
from typing import Iterator
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from re import Pattern, compile
|
from re import Pattern, compile
|
||||||
|
from .cli import Cli
|
||||||
|
from .utils import Triple
|
||||||
from .metadata import MetadataExtractor, Metadata
|
from .metadata import MetadataExtractor, Metadata
|
||||||
from .renders.cmake import CMakeProperties, CMakeRender
|
from .renders.cmake import CMakeDependency, CMakeProperties, CMakeRender
|
||||||
from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender
|
from .renders.pkgconfig import PkgConfigProperties, PkgConfigRender
|
||||||
|
|
||||||
|
|
||||||
class ArtifactContext:
|
class ArtifactContext:
|
||||||
"""A wrapper of metadata with proper fallback"""
|
"""A wrapper of metadata with proper fallback"""
|
||||||
|
|
||||||
|
__opts: Cli
|
||||||
__extractor: MetadataExtractor
|
__extractor: MetadataExtractor
|
||||||
__metadata: Metadata
|
__metadata: Metadata
|
||||||
|
|
||||||
def __init__(self, extractor: MetadataExtractor) -> None:
|
def __init__(self, opts: Cli, extractor: MetadataExtractor) -> None:
|
||||||
|
"""Wrap an extractor and eagerly fetch its :class:`Metadata`.
|
||||||
|
|
||||||
|
:param extractor: The extractor to wrap and read from.
|
||||||
|
"""
|
||||||
|
self.__opts = opts
|
||||||
self.__extractor = extractor
|
self.__extractor = extractor
|
||||||
self.__metadata = self.__extractor.get_metadata()
|
self.__metadata = self.__extractor.get_metadata()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def options(self) -> Cli:
|
||||||
|
"""The wrapped :class:`Cli` holding all command line arguments"""
|
||||||
|
return self.__opts
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def extractor(self) -> MetadataExtractor:
|
def extractor(self) -> MetadataExtractor:
|
||||||
|
"""The wrapped :class:`MetadataExtractor`."""
|
||||||
return self.__extractor
|
return self.__extractor
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def metadata(self) -> Metadata:
|
def metadata(self) -> Metadata:
|
||||||
|
"""The cached :class:`Metadata` read from the extractor."""
|
||||||
return self.__metadata
|
return self.__metadata
|
||||||
|
|
||||||
|
|
||||||
@@ -60,19 +82,28 @@ def _extract_common_prefix(pattern: str) -> tuple[str, str]:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class FileCopyInfo:
|
class FileCopyInfo:
|
||||||
|
"""A single on-disk file to copy into the distribution."""
|
||||||
|
|
||||||
from_path: Path
|
from_path: Path
|
||||||
"""The absolute path pointing to source file without any wildcard"""
|
"""The absolute path pointing to source file without any wildcard"""
|
||||||
to_path: Path
|
to_path: Path
|
||||||
"""The path of destination file relative to the install directory"""
|
"""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
|
extractor = ctx.extractor
|
||||||
metadata = ctx.metadata
|
metadata = ctx.metadata
|
||||||
project_root = Path(extractor.get_project_dir())
|
project_root = Path(extractor.get_project_dir())
|
||||||
rv: list[FileCopyInfo] = []
|
|
||||||
|
|
||||||
for header in metadata.headers:
|
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)
|
to_path = Path("include") / Path(header.to_path)
|
||||||
if _is_glob_pattern(header.from_path):
|
if _is_glob_pattern(header.from_path):
|
||||||
(common_prefix, glob_residue) = _extract_common_prefix(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:
|
# YYC MARK:
|
||||||
# Built ``subpath`` is prefix with ``new_project_root``
|
# Built ``subpath`` is prefix with ``new_project_root``
|
||||||
relative_subpath = subpath.relative_to(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:
|
else:
|
||||||
rv.append(
|
yield FileCopyInfo(
|
||||||
FileCopyInfo(
|
|
||||||
project_root / header.from_path, to_path / header.from_path
|
project_root / header.from_path, to_path / header.from_path
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return tuple(rv)
|
|
||||||
|
|
||||||
|
|
||||||
def _generate_dll_artifact_name(name: str) -> str:
|
_WINDOWS_ENV_IDENTS: tuple[str, ...] = ("windows", "cygwin")
|
||||||
match sys.platform:
|
|
||||||
case "win32" | "cygwin":
|
_LINUX_ENV_IDENTS: tuple[str, ...] = (
|
||||||
|
"linux",
|
||||||
|
"android",
|
||||||
|
"freebsd",
|
||||||
|
"openbsd",
|
||||||
|
"netbsd",
|
||||||
|
"haiku",
|
||||||
|
"hurd",
|
||||||
|
)
|
||||||
|
|
||||||
|
_MACOS_ENV_IDENTS: tuple[str, ...] = ("macos", "ios", "tvos", "visionos")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_windows_env(triple: Triple) -> bool:
|
||||||
|
return triple.operating_system in _WINDOWS_ENV_IDENTS
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_dll_artifact_name(name: str, triple: Triple) -> str:
|
||||||
|
"""Return the platform-appropriate dynamic-loadable file name for ``name``."""
|
||||||
|
match triple.operating_system:
|
||||||
|
case x if x in _WINDOWS_ENV_IDENTS:
|
||||||
return f"{name}.dll"
|
return f"{name}.dll"
|
||||||
case "linux" | "android" | "freebsd":
|
case x if x in _LINUX_ENV_IDENTS:
|
||||||
return f"lib{name}.so"
|
return f"lib{name}.so"
|
||||||
case "darwin" | "ios":
|
case x if x in _MACOS_ENV_IDENTS:
|
||||||
return f"lib{name}.dylib"
|
return f"lib{name}.dylib"
|
||||||
case _:
|
case _:
|
||||||
raise RuntimeError("not supported system")
|
raise RuntimeError("not supported system")
|
||||||
|
|
||||||
|
|
||||||
def _generate_lib_artifact_name(name: str) -> str:
|
def _generate_lib_artifact_name(name: str, _: Triple) -> str:
|
||||||
|
"""Return the Windows import-library file name for ``name``.
|
||||||
|
|
||||||
|
Only meaningful on Windows; returned regardless of platform for use by the
|
||||||
|
CMake properties.
|
||||||
|
"""
|
||||||
# only Windows has this feature, so we simply return it
|
# only Windows has this feature, so we simply return it
|
||||||
return f"{name}.dll.lib"
|
return f"{name}.dll.lib"
|
||||||
|
|
||||||
|
|
||||||
def resolve_lib_copy(ctx: ArtifactContext) -> 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
|
extractor = ctx.extractor
|
||||||
target_directory = extractor.get_target_directory() / "release"
|
|
||||||
target_name = extractor.get_target_name()
|
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
|
# copy artifact for redist
|
||||||
dll_artifact_filename = _generate_dll_artifact_name(target_name)
|
dll_artifact_filename = _generate_dll_artifact_name(target_name, target_triple)
|
||||||
rv.append(
|
yield FileCopyInfo(
|
||||||
FileCopyInfo(
|
|
||||||
target_directory / dll_artifact_filename,
|
target_directory / dll_artifact_filename,
|
||||||
Path("bin" if sys.platform == "win32" else "lib") / dll_artifact_filename,
|
Path("bin" if _is_windows_env(target_triple) else "lib")
|
||||||
)
|
/ dll_artifact_filename,
|
||||||
)
|
)
|
||||||
# copy artifact for linking only on windows
|
# copy artifact for linking only on windows
|
||||||
if sys.platform == "win32":
|
if _is_windows_env(target_triple):
|
||||||
lib_artifact_filename = _generate_lib_artifact_name(target_name)
|
lib_artifact_filename = _generate_lib_artifact_name(target_name, target_triple)
|
||||||
rv.append(
|
yield FileCopyInfo(
|
||||||
FileCopyInfo(
|
|
||||||
target_directory / lib_artifact_filename,
|
target_directory / lib_artifact_filename,
|
||||||
Path("lib") / lib_artifact_filename,
|
Path("lib") / lib_artifact_filename,
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|
||||||
return tuple(rv)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class TextCopyInfo:
|
class TextCopyInfo:
|
||||||
|
"""A piece of generated text to write into the distribution."""
|
||||||
|
|
||||||
text: str
|
text: str
|
||||||
"""The content of source file"""
|
"""The content of source file"""
|
||||||
to_path: Path
|
to_path: Path
|
||||||
@@ -149,6 +208,10 @@ _BAD_NAME_PATTERN: Pattern = compile(r"[^a-zA-Z0-9_+-]")
|
|||||||
|
|
||||||
|
|
||||||
def _sanitize_name(name: str) -> str:
|
def _sanitize_name(name: str) -> str:
|
||||||
|
"""Strip characters disallowed in generated identifiers from ``name``.
|
||||||
|
|
||||||
|
:raises ValueError: if nothing remains after stripping.
|
||||||
|
"""
|
||||||
new_name = _BAD_NAME_PATTERN.sub("", name)
|
new_name = _BAD_NAME_PATTERN.sub("", name)
|
||||||
if new_name == "":
|
if new_name == "":
|
||||||
raise ValueError("name is blank after sanitizing")
|
raise ValueError("name is blank after sanitizing")
|
||||||
@@ -156,11 +219,17 @@ def _sanitize_name(name: str) -> str:
|
|||||||
return new_name
|
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
|
extractor = ctx.extractor
|
||||||
metadata = ctx.metadata
|
metadata = ctx.metadata
|
||||||
target_name = extractor.get_target_name()
|
target_name = extractor.get_target_name()
|
||||||
|
|
||||||
|
# get triple info
|
||||||
|
target_triple = extractor.get_host_triple()
|
||||||
|
if ctx.options.target is not None:
|
||||||
|
target_triple = ctx.options.target
|
||||||
|
|
||||||
# compute fallback name
|
# compute fallback name
|
||||||
cmake_fallback_name = _sanitize_name(target_name)
|
cmake_fallback_name = _sanitize_name(target_name)
|
||||||
# get namespace and target name with fallback
|
# get namespace and target name with fallback
|
||||||
@@ -172,18 +241,26 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
|||||||
if metadata.cmake.target_name is not None:
|
if metadata.cmake.target_name is not None:
|
||||||
cmake_target_name = metadata.cmake.target_name
|
cmake_target_name = metadata.cmake.target_name
|
||||||
|
|
||||||
|
# gather declared dependencies (with metadata -> render conversion)
|
||||||
|
cmake_dependencies: tuple[CMakeDependency, ...] = tuple()
|
||||||
|
if metadata.cmake is not None and metadata.cmake.dependencies is not None:
|
||||||
|
cmake_dependencies = tuple(
|
||||||
|
CMakeDependency(dep.package, dep.target)
|
||||||
|
for dep in metadata.cmake.dependencies
|
||||||
|
)
|
||||||
|
|
||||||
# build cmake properties and render
|
# build cmake properties and render
|
||||||
properties = CMakeProperties(
|
properties = CMakeProperties(
|
||||||
cmake_namespace_name,
|
cmake_namespace_name,
|
||||||
cmake_target_name,
|
cmake_target_name,
|
||||||
_generate_dll_artifact_name(target_name),
|
_generate_dll_artifact_name(target_name, target_triple),
|
||||||
_generate_lib_artifact_name(target_name),
|
_generate_lib_artifact_name(target_name, target_triple),
|
||||||
extractor.get_version(),
|
extractor.get_version(),
|
||||||
|
cmake_dependencies,
|
||||||
)
|
)
|
||||||
render = CMakeRender(properties)
|
render = CMakeRender(properties)
|
||||||
# return infos
|
# return infos
|
||||||
return (
|
yield TextCopyInfo(
|
||||||
TextCopyInfo(
|
|
||||||
render.render_config(),
|
render.render_config(),
|
||||||
Path(
|
Path(
|
||||||
"lib",
|
"lib",
|
||||||
@@ -191,8 +268,8 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
|||||||
cmake_target_name,
|
cmake_target_name,
|
||||||
f"{cmake_target_name}Config.cmake",
|
f"{cmake_target_name}Config.cmake",
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
TextCopyInfo(
|
yield TextCopyInfo(
|
||||||
render.render_config_version(),
|
render.render_config_version(),
|
||||||
Path(
|
Path(
|
||||||
"lib",
|
"lib",
|
||||||
@@ -200,11 +277,11 @@ def resolve_cmake_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
|||||||
cmake_target_name,
|
cmake_target_name,
|
||||||
f"{cmake_target_name}ConfigVersion.cmake",
|
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
|
extractor = ctx.extractor
|
||||||
metadata = ctx.metadata
|
metadata = ctx.metadata
|
||||||
target_name = extractor.get_target_name()
|
target_name = extractor.get_target_name()
|
||||||
@@ -223,10 +300,16 @@ def resolve_pkgconfig_copy(ctx: ArtifactContext) -> tuple[TextCopyInfo, ...]:
|
|||||||
if pkgconfig_description is None:
|
if pkgconfig_description is None:
|
||||||
pkgconfig_description = ""
|
pkgconfig_description = ""
|
||||||
|
|
||||||
|
pkgconfig_requires: tuple[str, ...] = tuple()
|
||||||
|
if metadata.pkgconfig is not None and metadata.pkgconfig.requires is not None:
|
||||||
|
pkgconfig_requires = metadata.pkgconfig.requires
|
||||||
|
|
||||||
properties = PkgConfigProperties(
|
properties = PkgConfigProperties(
|
||||||
pkgconfig_name, pkgconfig_description, target_name, extractor.get_version()
|
pkgconfig_name,
|
||||||
|
pkgconfig_description,
|
||||||
|
target_name,
|
||||||
|
extractor.get_version(),
|
||||||
|
pkgconfig_requires,
|
||||||
)
|
)
|
||||||
render = PkgConfigRender(properties)
|
render = PkgConfigRender(properties)
|
||||||
return (
|
yield TextCopyInfo(render.render(), Path("lib", "pkgconfig", f"{pkgconfig_id}.pc"))
|
||||||
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
|
import argparse
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from .utils import Triple
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -23,6 +32,10 @@ class Cli:
|
|||||||
"""Destination of the zip archive bundling the distribution tree, or
|
"""Destination of the zip archive bundling the distribution tree, or
|
||||||
``None`` when no archive is requested."""
|
``None`` when no archive is requested."""
|
||||||
|
|
||||||
|
target: Triple | None
|
||||||
|
"""Target triple to pack for (e.g. ``x86_64-pc-windows-msvc``), or ``None``
|
||||||
|
to pack for the host toolchain."""
|
||||||
|
|
||||||
|
|
||||||
def parse() -> Cli:
|
def parse() -> Cli:
|
||||||
"""Parse command-line arguments into a :class:`Cli` instance.
|
"""Parse command-line arguments into a :class:`Cli` instance.
|
||||||
@@ -69,9 +82,20 @@ def parse() -> Cli:
|
|||||||
help="Path of the zip archive created from the dist-dir contents",
|
help="Path of the zip archive created from the dist-dir contents",
|
||||||
metavar="ZIP",
|
metavar="ZIP",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"-t",
|
||||||
|
"--target",
|
||||||
|
dest="target",
|
||||||
|
action="store",
|
||||||
|
type=Triple.parse,
|
||||||
|
required=False,
|
||||||
|
help="Target triple to pack for (e.g. x86_64-pc-windows-msvc). Defaults to the host toolchain.",
|
||||||
|
metavar="TRIPLE",
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
return Cli(
|
return Cli(
|
||||||
manifest=args.manifest,
|
manifest=args.manifest,
|
||||||
dist_dir=args.dist_dir,
|
dist_dir=args.dist_dir,
|
||||||
dist_zip=args.dist_zip,
|
dist_zip=args.dist_zip,
|
||||||
|
target=args.target,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,82 +1,223 @@
|
|||||||
|
"""Extraction and typed representation of cargo/OMRF metadata.
|
||||||
|
|
||||||
|
Defines the frozen :class:`MetadataHeader`/:class:`MetadataCMake`/
|
||||||
|
:class:`MetadataPkgConfig`/:class:`Metadata` models built from the
|
||||||
|
``[package.metadata.omrf]`` table, and :class:`MetadataExtractor`, which runs
|
||||||
|
``cargo metadata`` and exposes the relevant fields. The
|
||||||
|
:func:`wrap_metadata_errors` decorator gives every raised exception a uniform
|
||||||
|
``error occurs when fetching metadata`` context.
|
||||||
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from re import Pattern, compile
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
from semver import Version
|
from semver import Version
|
||||||
from . import utils
|
from . import utils
|
||||||
from .utils import dict_chain_get, dict_typed_get, dict_typed_get_required
|
from .utils import (
|
||||||
|
Triple,
|
||||||
|
VERSION,
|
||||||
|
dict_chain_get,
|
||||||
|
dict_typed_get,
|
||||||
|
dict_typed_get_required,
|
||||||
|
list_typed_iter,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MetadataHeader:
|
class MetadataHeader:
|
||||||
|
"""One ``headers`` asset entry from the OMRF metadata.
|
||||||
|
|
||||||
|
Describes a single header file (or, when ``from_path`` is a glob, a set of
|
||||||
|
files) to install into the ``include`` tree.
|
||||||
|
"""
|
||||||
|
|
||||||
from_path: str
|
from_path: str
|
||||||
|
"""Source path relative to the project root; may be a UNIX-style glob."""
|
||||||
to_path: str
|
to_path: str
|
||||||
|
"""Destination path relative to the ``include`` directory (``""`` means the root)."""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate that ``from_path`` is non-empty."""
|
||||||
if self.from_path == "":
|
if self.from_path == "":
|
||||||
raise ValueError("header 'from' must not be empty")
|
raise ValueError("header 'from' must not be empty")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(d: dict[str, Any]) -> "MetadataHeader":
|
def from_dict(d: dict[str, Any]) -> "MetadataHeader":
|
||||||
|
"""Build a :class:`MetadataHeader` from its raw TOML table."""
|
||||||
return MetadataHeader(
|
return MetadataHeader(
|
||||||
from_path=dict_typed_get_required(d, "from", str),
|
from_path=dict_typed_get_required(d, "from", str),
|
||||||
to_path=dict_typed_get(d, "to", str, ""),
|
to_path=dict_typed_get(d, "to", str, ""),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class MetadataCMakeDependency:
|
||||||
|
"""One CMake dependency declared in the OMRF metadata.
|
||||||
|
|
||||||
|
Carries the ``find_dependency`` package name and the link target to wire
|
||||||
|
into the imported target's ``INTERFACE_LINK_LIBRARIES``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
package: str
|
||||||
|
"""Package name passed to ``find_dependency`` (e.g. ``ZLIB``)."""
|
||||||
|
target: str
|
||||||
|
"""Link target wired into ``INTERFACE_LINK_LIBRARIES`` (e.g. ``ZLIB::ZLIB``)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate that ``package`` and ``target`` are non-empty strings."""
|
||||||
|
if self.package == "":
|
||||||
|
raise ValueError("bad cmake dependency package")
|
||||||
|
if self.target == "":
|
||||||
|
raise ValueError("bad cmake dependency target")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def from_dict(d: dict[str, Any]) -> "MetadataCMakeDependency":
|
||||||
|
"""Build a :class:`CMakeDependency` from its raw TOML table."""
|
||||||
|
return MetadataCMakeDependency(
|
||||||
|
package=dict_typed_get_required(d, "package", str),
|
||||||
|
target=dict_typed_get_required(d, "target", str),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MetadataCMake:
|
class MetadataCMake:
|
||||||
|
"""Optional ``[package.metadata.omrf.cmake]`` section."""
|
||||||
|
|
||||||
namespace_name: str | None
|
namespace_name: str | None
|
||||||
|
"""CMake target namespace, or ``None`` to fall back to the project name."""
|
||||||
target_name: str | None
|
target_name: str | None
|
||||||
|
"""CMake target name, or ``None`` to fall back to the project name."""
|
||||||
|
dependencies: tuple[MetadataCMakeDependency, ...] | None
|
||||||
|
"""Declared CMake dependencies, or ``None`` to no dependencies specified."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate the namespace and target names when specified.
|
||||||
|
|
||||||
|
:raises ValueError: if a specified name is not a legal name.
|
||||||
|
"""
|
||||||
|
if self.namespace_name is not None and not utils.is_good_name(
|
||||||
|
self.namespace_name
|
||||||
|
):
|
||||||
|
raise ValueError("bad cmake namespace name")
|
||||||
|
if self.target_name is not None and not utils.is_good_name(self.target_name):
|
||||||
|
raise ValueError("bad cmake target name")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(d: dict[str, Any]) -> "MetadataCMake":
|
def from_dict(d: dict[str, Any]) -> "MetadataCMake":
|
||||||
|
"""Build a :class:`MetadataCMake` from its raw TOML table."""
|
||||||
|
raw_dependencies = dict_typed_get(d, "dependencies", list)
|
||||||
|
dependencies = (
|
||||||
|
tuple(
|
||||||
|
MetadataCMakeDependency.from_dict(item)
|
||||||
|
for item in list_typed_iter(raw_dependencies, dict)
|
||||||
|
)
|
||||||
|
if raw_dependencies is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return MetadataCMake(
|
return MetadataCMake(
|
||||||
namespace_name=dict_typed_get(d, "namespace_name", str),
|
namespace_name=dict_typed_get(d, "namespace_name", str),
|
||||||
target_name=dict_typed_get(d, "target_name", str),
|
target_name=dict_typed_get(d, "target_name", str),
|
||||||
|
dependencies=dependencies,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class MetadataPkgConfig:
|
class MetadataPkgConfig:
|
||||||
|
"""Optional ``[package.metadata.omrf.pkgconfig]`` section."""
|
||||||
|
|
||||||
id: str | None
|
id: str | None
|
||||||
|
"""pkg-config package id (the ``.pc`` file stem), or ``None`` to fall back to the project name."""
|
||||||
name: str | None
|
name: str | None
|
||||||
|
"""Human-readable package name, or ``None`` to fall back to the project name."""
|
||||||
description: str | None
|
description: str | None
|
||||||
|
"""Brief package description, or ``None`` to fall back to the project description."""
|
||||||
|
requires: tuple[str, ...] | None
|
||||||
|
"""Declared public pkg-config dependencies, or ``None`` to no dependencies specified."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate the id, name and description when specified.
|
||||||
|
|
||||||
|
:raises ValueError: if a specified id is not a legal name, or a
|
||||||
|
specified name/description contains EOL characters.
|
||||||
|
"""
|
||||||
|
if self.id is not None and not utils.is_good_name(self.id):
|
||||||
|
raise ValueError("bad pkg-config id")
|
||||||
|
if self.name is not None and not utils.is_good_sentence(self.name):
|
||||||
|
raise ValueError("bad pkg-config name (has EOL chars)")
|
||||||
|
if self.description is not None and not utils.is_good_sentence(
|
||||||
|
self.description
|
||||||
|
):
|
||||||
|
raise ValueError("bad pkg-config description (has EOL chars)")
|
||||||
|
if self.requires is not None:
|
||||||
|
for req in self.requires:
|
||||||
|
if req == "":
|
||||||
|
raise ValueError("bad pkg-config require spec")
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig":
|
def from_dict(d: dict[str, Any]) -> "MetadataPkgConfig":
|
||||||
|
"""Build a :class:`MetadataPkgConfig` from its raw TOML table."""
|
||||||
|
raw_requires = dict_typed_get(d, "requires", list)
|
||||||
|
requires = (
|
||||||
|
tuple(list_typed_iter(raw_requires, str))
|
||||||
|
if raw_requires is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
return MetadataPkgConfig(
|
return MetadataPkgConfig(
|
||||||
id=dict_typed_get(d, "id", str),
|
id=dict_typed_get(d, "id", str),
|
||||||
name=dict_typed_get(d, "name", str),
|
name=dict_typed_get(d, "name", str),
|
||||||
description=dict_typed_get(d, "description", str),
|
description=dict_typed_get(d, "description", str),
|
||||||
|
requires=requires,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Metadata:
|
class Metadata:
|
||||||
|
"""Typed view of the whole ``[package.metadata.omrf]`` table."""
|
||||||
|
|
||||||
min_version: Version | None
|
min_version: Version | None
|
||||||
|
"""Minimum packer version required by the project, or ``None`` for no constraint."""
|
||||||
headers: tuple[MetadataHeader, ...]
|
headers: tuple[MetadataHeader, ...]
|
||||||
|
"""Header assets to distribute (required, possibly empty)."""
|
||||||
cmake: MetadataCMake | None
|
cmake: MetadataCMake | None
|
||||||
|
"""CMake override section, or ``None`` when absent."""
|
||||||
pkgconfig: MetadataPkgConfig | None
|
pkgconfig: MetadataPkgConfig | None
|
||||||
|
"""pkg-config override section, or ``None`` when absent."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Enforce ``min_version`` against the running packer version.
|
||||||
|
|
||||||
|
:raises RuntimeError: if ``min_version`` is set and newer than this packer.
|
||||||
|
"""
|
||||||
|
# check version restriction
|
||||||
|
this_version = self.min_version
|
||||||
|
if this_version is not None:
|
||||||
|
if this_version > VERSION:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"requested minimum version is not fulfilled. {this_version} required got {VERSION}"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def from_dict(d: dict[str, Any]) -> "Metadata":
|
def from_dict(d: dict[str, Any]) -> "Metadata":
|
||||||
|
"""Build a :class:`Metadata` from the raw ``[package.metadata.omrf]`` table."""
|
||||||
raw_min_version = dict_typed_get(d, "min_version", str)
|
raw_min_version = dict_typed_get(d, "min_version", str)
|
||||||
if raw_min_version is not None:
|
if raw_min_version is not None:
|
||||||
min_version = utils.parse_version(raw_min_version)
|
min_version = utils.parse_version(raw_min_version)
|
||||||
else:
|
else:
|
||||||
min_version = None
|
min_version = None
|
||||||
|
|
||||||
raw_headers = dict_typed_get_required(d, "headers", list)
|
headers = tuple(
|
||||||
headers_list: list[MetadataHeader] = []
|
MetadataHeader.from_dict(item)
|
||||||
for i, item in enumerate(raw_headers):
|
for item in list_typed_iter(
|
||||||
if not isinstance(item, dict):
|
dict_typed_get_required(d, "headers", list), dict
|
||||||
raise TypeError(f"headers[{i}] must be a table")
|
)
|
||||||
headers_list.append(MetadataHeader.from_dict(item))
|
)
|
||||||
headers = tuple(headers_list)
|
|
||||||
|
|
||||||
raw_cmake = dict_typed_get(d, "cmake", dict)
|
raw_cmake = dict_typed_get(d, "cmake", dict)
|
||||||
cmake = MetadataCMake.from_dict(raw_cmake) if raw_cmake is not None else None
|
cmake = MetadataCMake.from_dict(raw_cmake) if raw_cmake is not None else None
|
||||||
@@ -97,6 +238,13 @@ class Metadata:
|
|||||||
|
|
||||||
|
|
||||||
def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
||||||
|
"""Decorator that wraps a function's exceptions in a uniform metadata context.
|
||||||
|
|
||||||
|
Any :class:`Exception` raised by the wrapped function is re-raised as
|
||||||
|
``RuntimeError("error occurs when fetching metadata: ...")`` chained to the
|
||||||
|
original cause.
|
||||||
|
"""
|
||||||
|
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||||
try:
|
try:
|
||||||
@@ -107,15 +255,37 @@ def wrap_metadata_errors[**P, R](func: Callable[P, R]) -> Callable[P, R]:
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
_SUBPROCESS_TIMEOUT_SEC: int = 10
|
||||||
|
"""Timeout in seconds for the ``cargo``/``rustc`` subprocesses launched below."""
|
||||||
|
|
||||||
|
_HOST_TRIPLE_PATTERN: Pattern = compile(r"(?m)^host:\s*(\S+)$")
|
||||||
|
"""The pattern for match host triple in ``rustc -vV``. The group 1 is the triple result."""
|
||||||
|
|
||||||
|
|
||||||
class MetadataExtractor:
|
class MetadataExtractor:
|
||||||
|
"""Access layer over ``cargo metadata`` and the OMRF metadata table.
|
||||||
|
|
||||||
|
On construction it runs ``cargo metadata``, locates the user-requested
|
||||||
|
package and its main library target, and caches the raw dicts. The getter
|
||||||
|
methods expose typed views of individual fields; :meth:`get_metadata`
|
||||||
|
returns the parsed :class:`Metadata` model. Every getter is wrapped by
|
||||||
|
:func:`wrap_metadata_errors`.
|
||||||
|
"""
|
||||||
|
|
||||||
__metadata: dict[str, Any]
|
__metadata: dict[str, Any]
|
||||||
"""The direct output of ``cargo metadata``"""
|
"""The direct output of ``cargo metadata``"""
|
||||||
__metadata_package: dict[str, Any]
|
__metadata_package: dict[str, Any]
|
||||||
"""The package item in cargo metadata's packages list pointing to user request package"""
|
"""The package item in cargo metadata's packages list pointing to user request package"""
|
||||||
__metadata_target: dict[str, Any]
|
__metadata_target: dict[str, Any]
|
||||||
"""The target item in package item's targets list pointing to the main target"""
|
"""The target item in package item's targets list pointing to the main target"""
|
||||||
|
__host_triple: Triple
|
||||||
|
"""The host toolchain triple resolved from ``rustc -vV``"""
|
||||||
|
|
||||||
def __init__(self, cargo_toml_path: Path) -> None:
|
def __init__(self, cargo_toml_path: Path) -> None:
|
||||||
|
"""Run ``cargo metadata`` and locate the package and main target.
|
||||||
|
|
||||||
|
:param cargo_toml_path: Path to the ``Cargo.toml`` of the project to pack.
|
||||||
|
"""
|
||||||
self.__metadata = MetadataExtractor.__extract_metadata(cargo_toml_path)
|
self.__metadata = MetadataExtractor.__extract_metadata(cargo_toml_path)
|
||||||
self.__metadata_package = MetadataExtractor.__extract_metadata_package(
|
self.__metadata_package = MetadataExtractor.__extract_metadata_package(
|
||||||
self.__metadata, cargo_toml_path
|
self.__metadata, cargo_toml_path
|
||||||
@@ -123,10 +293,44 @@ class MetadataExtractor:
|
|||||||
self.__metadata_target = MetadataExtractor.__extract_metadata_target(
|
self.__metadata_target = MetadataExtractor.__extract_metadata_target(
|
||||||
self.__metadata_package, cargo_toml_path
|
self.__metadata_package, cargo_toml_path
|
||||||
)
|
)
|
||||||
|
self.__host_triple = MetadataExtractor.__extract_host_triple()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
@wrap_metadata_errors
|
||||||
|
def __extract_host_triple() -> Triple:
|
||||||
|
"""Query ``rustc -vV`` and parse its ``host:`` line into a :class:`Triple`.
|
||||||
|
|
||||||
|
:raises RuntimeError: if rustc times out, exits with a non-zero status,
|
||||||
|
or its output carries no ``host:`` line.
|
||||||
|
"""
|
||||||
|
rustc = os.getenv("OMRF_PACKER_RUSTC", "rustc")
|
||||||
|
proc = subprocess.Popen(
|
||||||
|
[rustc, "-vV"], stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT_SEC)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
proc.kill()
|
||||||
|
proc.communicate()
|
||||||
|
raise RuntimeError("fail to fetch host triple: timed out")
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"fail to fetch host triple: " + stderr.decode("utf-8", errors="ignore")
|
||||||
|
)
|
||||||
|
m = _HOST_TRIPLE_PATTERN.search(stdout.decode("utf-8", errors="strict"))
|
||||||
|
if m is None:
|
||||||
|
raise RuntimeError("can not find host triple in rustc -vV output")
|
||||||
|
return Triple.parse(m.group(1))
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def __extract_metadata(cargo_toml_path: Path) -> dict[str, Any]:
|
def __extract_metadata(cargo_toml_path: Path) -> dict[str, Any]:
|
||||||
|
"""Invoke ``cargo metadata`` and return its parsed JSON output.
|
||||||
|
|
||||||
|
:param cargo_toml_path: Path to the manifest passed via ``--manifest-path``.
|
||||||
|
:returns: Parsed ``cargo metadata`` output.
|
||||||
|
:raises RuntimeError: if cargo times out or exits with a non-zero status.
|
||||||
|
"""
|
||||||
cargo_bin = os.getenv("OMRF_PACKER_CARGO", "cargo")
|
cargo_bin = os.getenv("OMRF_PACKER_CARGO", "cargo")
|
||||||
cmd = [
|
cmd = [
|
||||||
cargo_bin,
|
cargo_bin,
|
||||||
@@ -139,7 +343,7 @@ class MetadataExtractor:
|
|||||||
]
|
]
|
||||||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||||
try:
|
try:
|
||||||
stdout, stderr = proc.communicate(timeout=10)
|
stdout, stderr = proc.communicate(timeout=_SUBPROCESS_TIMEOUT_SEC)
|
||||||
except subprocess.TimeoutExpired:
|
except subprocess.TimeoutExpired:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
proc.communicate()
|
proc.communicate()
|
||||||
@@ -157,11 +361,15 @@ class MetadataExtractor:
|
|||||||
def __extract_metadata_package(
|
def __extract_metadata_package(
|
||||||
cargo_metadata: dict[str, Any], cargo_toml_path: Path
|
cargo_metadata: dict[str, Any], cargo_toml_path: Path
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
packages: list[Any] = dict_typed_get_required(cargo_metadata, "packages", list)
|
"""Find the package whose ``manifest_path`` matches the given manifest.
|
||||||
for i, package in enumerate(packages):
|
|
||||||
if not isinstance(package, dict):
|
|
||||||
raise TypeError(f"packages[{i}] must be a table")
|
|
||||||
|
|
||||||
|
:param cargo_metadata: Parsed ``cargo metadata`` output.
|
||||||
|
:param cargo_toml_path: Manifest path of the user-requested package.
|
||||||
|
:returns: The matching package item.
|
||||||
|
:raises RuntimeError: if no matching package is found.
|
||||||
|
"""
|
||||||
|
packages = dict_typed_get_required(cargo_metadata, "packages", list)
|
||||||
|
for package in list_typed_iter(packages, dict):
|
||||||
raw_manifest_path = dict_typed_get_required(package, "manifest_path", str)
|
raw_manifest_path = dict_typed_get_required(package, "manifest_path", str)
|
||||||
manifest_path = Path(raw_manifest_path)
|
manifest_path = Path(raw_manifest_path)
|
||||||
if manifest_path == cargo_toml_path:
|
if manifest_path == cargo_toml_path:
|
||||||
@@ -173,14 +381,18 @@ class MetadataExtractor:
|
|||||||
def __extract_metadata_target(
|
def __extract_metadata_target(
|
||||||
cargo_package: dict[str, Any], cargo_toml_path: Path
|
cargo_package: dict[str, Any], cargo_toml_path: Path
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Find the library target (the one with ``src/lib.rs``) of a package.
|
||||||
|
|
||||||
|
:param cargo_package: A package item from ``cargo metadata``.
|
||||||
|
:param cargo_toml_path: Manifest path used to derive the ``src/lib.rs`` location.
|
||||||
|
:returns: The matching target item.
|
||||||
|
:raises RuntimeError: if no library target is found.
|
||||||
|
"""
|
||||||
# build the path to lib.rs for comparing
|
# build the path to lib.rs for comparing
|
||||||
librs = cargo_toml_path.parent / "src" / "lib.rs"
|
librs = cargo_toml_path.parent / "src" / "lib.rs"
|
||||||
# start checking
|
# start checking
|
||||||
targets: list[Any] = dict_typed_get_required(cargo_package, "targets", list)
|
targets = dict_typed_get_required(cargo_package, "targets", list)
|
||||||
for i, target in enumerate(targets):
|
for target in list_typed_iter(targets, dict):
|
||||||
if not isinstance(target, dict):
|
|
||||||
raise TypeError(f"targets[{i}] must be a table")
|
|
||||||
|
|
||||||
raw_src_path = dict_typed_get_required(target, "src_path", str)
|
raw_src_path = dict_typed_get_required(target, "src_path", str)
|
||||||
src_path = Path(raw_src_path)
|
src_path = Path(raw_src_path)
|
||||||
if src_path == librs:
|
if src_path == librs:
|
||||||
@@ -192,19 +404,24 @@ class MetadataExtractor:
|
|||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def get_target_directory(self) -> Path:
|
def get_target_directory(self) -> Path:
|
||||||
"""Get the absolute path to directory where Rust target directory is"""
|
"""Get the absolute path to directory where Rust target directory is"""
|
||||||
raw_target_directory = dict_typed_get_required(self.__metadata, "target_directory", str)
|
raw_target_directory = dict_typed_get_required(
|
||||||
|
self.__metadata, "target_directory", str
|
||||||
|
)
|
||||||
return Path(raw_target_directory)
|
return Path(raw_target_directory)
|
||||||
|
|
||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def get_name(self) -> str:
|
def get_name(self) -> str:
|
||||||
|
"""Return the package name as reported by cargo."""
|
||||||
return dict_typed_get_required(self.__metadata_package, "name", str)
|
return dict_typed_get_required(self.__metadata_package, "name", str)
|
||||||
|
|
||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def get_description(self) -> str | None:
|
def get_description(self) -> str | None:
|
||||||
|
"""Return the package description, or ``None`` if unset."""
|
||||||
return dict_typed_get(self.__metadata_package, "description", str)
|
return dict_typed_get(self.__metadata_package, "description", str)
|
||||||
|
|
||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def get_version(self) -> Version:
|
def get_version(self) -> Version:
|
||||||
|
"""Return the package version as a validated :class:`semver.Version`."""
|
||||||
raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
|
raw_version = dict_typed_get_required(self.__metadata_package, "version", str)
|
||||||
return utils.parse_version(raw_version)
|
return utils.parse_version(raw_version)
|
||||||
|
|
||||||
@@ -218,9 +435,15 @@ class MetadataExtractor:
|
|||||||
|
|
||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def get_metadata(self) -> Metadata:
|
def get_metadata(self) -> Metadata:
|
||||||
|
"""Return the parsed :class:`Metadata` model from the OMRF table."""
|
||||||
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
|
omrf = dict_chain_get(self.__metadata_package, "metadata", "omrf")
|
||||||
return Metadata.from_dict(omrf)
|
return Metadata.from_dict(omrf)
|
||||||
|
|
||||||
@wrap_metadata_errors
|
@wrap_metadata_errors
|
||||||
def get_target_name(self) -> str:
|
def get_target_name(self) -> str:
|
||||||
|
"""Return the name of the main library target."""
|
||||||
return dict_typed_get_required(self.__metadata_target, "name", str)
|
return dict_typed_get_required(self.__metadata_target, "name", str)
|
||||||
|
|
||||||
|
def get_host_triple(self) -> Triple:
|
||||||
|
"""Return the host toolchain triple resolved at construction time."""
|
||||||
|
return self.__host_triple
|
||||||
|
|||||||
@@ -14,6 +14,27 @@ from . import common
|
|||||||
from .. import utils
|
from .. import utils
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CMakeDependency:
|
||||||
|
"""One CMake dependency consumed by the renderer.
|
||||||
|
|
||||||
|
Carries the ``find_dependency`` package name and the link target wired into
|
||||||
|
the imported target's ``INTERFACE_LINK_LIBRARIES``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
package: str
|
||||||
|
"""Package name passed to ``find_dependency`` (e.g. ``ZLIB``)."""
|
||||||
|
target: str
|
||||||
|
"""Link target wired into ``INTERFACE_LINK_LIBRARIES`` (e.g. ``ZLIB::ZLIB``)."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Validate that ``package`` and ``target`` are non-empty strings."""
|
||||||
|
if self.package == "":
|
||||||
|
raise ValueError("bad cmake dependency package")
|
||||||
|
if self.target == "":
|
||||||
|
raise ValueError("bad cmake dependency target")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CMakeProperties:
|
class CMakeProperties:
|
||||||
"""Inputs required to render the CMake package-configuration files.
|
"""Inputs required to render the CMake package-configuration files.
|
||||||
@@ -44,13 +65,16 @@ class CMakeProperties:
|
|||||||
|
|
||||||
version: Version
|
version: Version
|
||||||
"""Version of the library. Must not carry a prerelease or build component."""
|
"""Version of the library. Must not carry a prerelease or build component."""
|
||||||
|
dependencies: tuple[CMakeDependency, ...]
|
||||||
|
"""CMake dependencies to ``find_dependency`` and link into the target
|
||||||
|
(empty when none are declared)."""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Validate the fields after construction.
|
"""Validate the fields after construction.
|
||||||
|
|
||||||
:raises ValueError: if the namespace or target name contains characters
|
:raises ValueError: if the namespace or target name contains characters
|
||||||
outside the CMake name pattern, an artifact file name is empty, or
|
outside the CMake name pattern, an artifact file name is empty, or the
|
||||||
the version carries a prerelease or build component (unsupported by
|
version carries a prerelease or build component (unsupported by
|
||||||
CMake config-version files).
|
CMake config-version files).
|
||||||
"""
|
"""
|
||||||
if not utils.is_good_name(self.namespace_name):
|
if not utils.is_good_name(self.namespace_name):
|
||||||
@@ -99,6 +123,7 @@ class CMakeRender:
|
|||||||
"target": self.__properties.target_name,
|
"target": self.__properties.target_name,
|
||||||
"artifact_dll": self.__properties.artifact_dll,
|
"artifact_dll": self.__properties.artifact_dll,
|
||||||
"artifact_lib": self.__properties.artifact_lib,
|
"artifact_lib": self.__properties.artifact_lib,
|
||||||
|
"dependencies": self.__properties.dependencies,
|
||||||
}
|
}
|
||||||
return self.__render.render("XXXConfig.cmake.jinja", payload)
|
return self.__render.render("XXXConfig.cmake.jinja", payload)
|
||||||
|
|
||||||
|
|||||||
@@ -9,25 +9,11 @@ rendering on top of :class:`renders.common.BaseRender`.
|
|||||||
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from re import Pattern, compile
|
|
||||||
from . import common
|
from . import common
|
||||||
from .. import utils
|
from .. import utils
|
||||||
from semver import Version
|
from semver import Version
|
||||||
|
|
||||||
|
|
||||||
_EOL_PATTERN: Pattern = compile(r"[\r\n]")
|
|
||||||
|
|
||||||
|
|
||||||
def _is_good_sentence(s: str) -> bool:
|
|
||||||
"""Check whether given string has EOL chars.
|
|
||||||
|
|
||||||
This function is used for checking human-readable name and description,
|
|
||||||
because EOL chars are not allowed in these properties.
|
|
||||||
:returns: True if given string don't contain any EOL chars, otherwise false.
|
|
||||||
"""
|
|
||||||
return _EOL_PATTERN.search(s) is None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PkgConfigProperties:
|
class PkgConfigProperties:
|
||||||
"""Inputs required to render the pkg-config ``.pc`` file.
|
"""Inputs required to render the pkg-config ``.pc`` file.
|
||||||
@@ -56,17 +42,18 @@ class PkgConfigProperties:
|
|||||||
|
|
||||||
version: Version
|
version: Version
|
||||||
"""Version of the library. Must not carry a prerelease or build component."""
|
"""Version of the library. Must not carry a prerelease or build component."""
|
||||||
|
requires: tuple[str, ...]
|
||||||
|
"""Public pkg-config dependencies (empty when none are declared)."""
|
||||||
|
|
||||||
def __post_init__(self) -> None:
|
def __post_init__(self) -> None:
|
||||||
"""Validate the fields after construction.
|
"""Validate the fields after construction.
|
||||||
|
|
||||||
:raises ValueError: if the name or description is blank, or the
|
:raises ValueError: if the name or description is blank, or the version
|
||||||
version carries a prerelease or build component (unsupported in
|
carries a prerelease or build component (unsupported in pkg-config).
|
||||||
pkg-config).
|
|
||||||
"""
|
"""
|
||||||
if self.name == "" or not _is_good_sentence(self.name):
|
if self.name == "" or not utils.is_good_sentence(self.name):
|
||||||
raise ValueError("bad name (blank or has EOL chars) of pkg-config")
|
raise ValueError("bad name (blank or has EOL chars) of pkg-config")
|
||||||
if self.description == "" or not _is_good_sentence(self.description):
|
if self.description == "" or not utils.is_good_sentence(self.description):
|
||||||
raise ValueError("bad description (blank or has EOL chars) of pkg-config")
|
raise ValueError("bad description (blank or has EOL chars) of pkg-config")
|
||||||
if not utils.is_good_name(self.artifact):
|
if not utils.is_good_name(self.artifact):
|
||||||
raise ValueError("bad artifact name in pkg-config")
|
raise ValueError("bad artifact name in pkg-config")
|
||||||
@@ -102,6 +89,8 @@ class PkgConfigRender:
|
|||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"name": self.__properties.name,
|
"name": self.__properties.name,
|
||||||
"description": self.__properties.description,
|
"description": self.__properties.description,
|
||||||
|
"version": str(self.__properties.version),
|
||||||
"artifact": self.__properties.artifact,
|
"artifact": self.__properties.artifact,
|
||||||
|
"requires": self.__properties.requires,
|
||||||
}
|
}
|
||||||
return self.__render.render("XXX.pc.jinja", payload)
|
return self.__render.render("XXX.pc.jinja", payload)
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# This file is created by sarasacw-omrf-packer and should not be changed manually.
|
||||||
|
|
||||||
{#
|
{#
|
||||||
Utilize ${pcfiledir} to fetch the directory where current .pc file is.
|
Utilize ${pcfiledir} to fetch the directory where current .pc file is.
|
||||||
And back to parent twice to obtain the installation directory.
|
And back to parent twice to obtain the installation directory.
|
||||||
@@ -10,5 +12,8 @@ includedir=${prefix}/include
|
|||||||
Name: {{ name }}
|
Name: {{ name }}
|
||||||
Description: {{ description }}
|
Description: {{ description }}
|
||||||
Version: {{ version }}
|
Version: {{ version }}
|
||||||
|
{%- if requires | length != 0 %}
|
||||||
|
Requires: {{ requires | join(", ") }}
|
||||||
|
{%- endif %}
|
||||||
Libs: -L${libdir} -l{{ artifact }}
|
Libs: -L${libdir} -l{{ artifact }}
|
||||||
Cflags: -I${includedir}
|
Cflags: -I${includedir}
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# This file is created by sarasacw-omrf-packer and should not be changed manually.
|
||||||
|
|
||||||
{# Check whether this target is already exported -#}
|
{# Check whether this target is already exported -#}
|
||||||
if(TARGET {{ namespace }}::{{ target }})
|
if(TARGET {{ namespace }}::{{ target }})
|
||||||
message(STATUS "Target {{ namespace }}::{{ target }} is already defined.")
|
message(STATUS "Target {{ namespace }}::{{ target }} is already defined.")
|
||||||
@@ -16,21 +18,45 @@ set(MyRust_LIBRARY "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}")
|
|||||||
set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include")
|
set(MyRust_INCLUDE_DIR "${_IMPORT_PREFIX}/include")
|
||||||
|
|
||||||
{# Create modern CMake target (IMPORTED target) -#}
|
{# Create modern CMake target (IMPORTED target) -#}
|
||||||
add_library({{ namespace }}::{{ target }} SHARED IMPORTED)
|
add_library({{ target }} SHARED IMPORTED)
|
||||||
set_target_properties({{ namespace }}::{{ target }} PROPERTIES
|
add_library({{ namespace }}::{{ target }} ALIAS {{ target }})
|
||||||
|
set_target_properties({{ target }} PROPERTIES
|
||||||
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
|
INTERFACE_INCLUDE_DIRECTORIES "${_IMPORT_PREFIX}/include"
|
||||||
)
|
)
|
||||||
{# Handle Windows and UNIX-like respectively -#}
|
{# Handle Windows and UNIX-like respectively -#}
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
set_target_properties({{ namespace }}::{{ target }} PROPERTIES
|
set_target_properties({{ target }} PROPERTIES
|
||||||
IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}"
|
IMPORTED_LOCATION "${_IMPORT_PREFIX}/bin/{{ artifact_dll }}"
|
||||||
IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}"
|
IMPORTED_IMPLIB "${_IMPORT_PREFIX}/lib/{{ artifact_lib }}"
|
||||||
)
|
)
|
||||||
else()
|
else()
|
||||||
set_target_properties({{ namespace }}::{{ target }} PROPERTIES
|
set_target_properties({{ target }} PROPERTIES
|
||||||
IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}"
|
IMPORTED_LOCATION "${_IMPORT_PREFIX}/lib/{{ artifact_dll }}"
|
||||||
)
|
)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
{# Cleanup temporary variables -#}
|
{# Cleanup temporary variables -#}
|
||||||
set(_IMPORT_PREFIX)
|
set(_IMPORT_PREFIX)
|
||||||
|
|
||||||
|
{% if dependencies | length != 0 -%}
|
||||||
|
{# Pull in declared dependencies and wire them into the imported target -#}
|
||||||
|
include(CMakeFindDependencyMacro)
|
||||||
|
{% for dep in dependencies -%}
|
||||||
|
find_dependency({{ dep.package }})
|
||||||
|
{% endfor -%}
|
||||||
|
set_target_properties({{ target }} PROPERTIES
|
||||||
|
INTERFACE_LINK_LIBRARIES "{{ dependencies | map(attribute='target') | join(';') }}"
|
||||||
|
)
|
||||||
|
{%- endif %}
|
||||||
|
|
||||||
|
{# Verify component (although there is no component) -#}
|
||||||
|
macro(check_required_components _NAME)
|
||||||
|
foreach(comp ${${_NAME}_FIND_COMPONENTS})
|
||||||
|
if(NOT ${_NAME}_${comp}_FOUND)
|
||||||
|
if(${_NAME}_FIND_REQUIRED_${comp})
|
||||||
|
set(${_NAME}_FOUND FALSE)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endmacro()
|
||||||
|
check_required_components({{ target }})
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
|
"""Shared helpers used across sarasacw-omrf-packer.
|
||||||
|
|
||||||
|
Provides version parsing/checking, typed dictionary access helpers, name
|
||||||
|
validation and template-directory resolution.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from re import Pattern, compile
|
from re import Pattern, compile
|
||||||
from typing import Any, overload
|
from typing import Any, Iterator, overload
|
||||||
from semver import Version
|
from semver import Version
|
||||||
|
|
||||||
VERSION: Version = Version(1, 0, 0)
|
VERSION: Version = Version(1, 0, 0)
|
||||||
@@ -15,6 +22,7 @@ def parse_version(vs: str) -> Version:
|
|||||||
else:
|
else:
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|
||||||
def is_good_version(v: Version) -> bool:
|
def is_good_version(v: Version) -> bool:
|
||||||
"""Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package.
|
"""Check whether given version has ``prerelease`` or ``build`` components which is not allowed in this package.
|
||||||
|
|
||||||
@@ -22,19 +30,39 @@ def is_good_version(v: Version) -> bool:
|
|||||||
"""
|
"""
|
||||||
return v.prerelease is None and v.build is None
|
return v.prerelease is None and v.build is None
|
||||||
|
|
||||||
|
|
||||||
def get_root_dir() -> Path:
|
def get_root_dir() -> Path:
|
||||||
|
"""Return the resolved directory that contains this package."""
|
||||||
return Path(__file__).resolve().parent
|
return Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
def get_templates_dir() -> Path:
|
def get_templates_dir() -> Path:
|
||||||
|
"""Return the directory holding the Jinja2 templates (``<root>/templates``)."""
|
||||||
return get_root_dir() / "templates"
|
return get_root_dir() / "templates"
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ...
|
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T]) -> T | None: ...
|
||||||
@overload
|
@overload
|
||||||
def dict_typed_get[T, D](d: dict[str, Any], key: str, ty: type[T], default: D) -> T | D: ...
|
def dict_typed_get[T, D](
|
||||||
def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = None) -> Any:
|
d: dict[str, Any], key: str, ty: type[T], default: D
|
||||||
|
) -> T | D: ...
|
||||||
|
def dict_typed_get[T](
|
||||||
|
d: dict[str, Any], key: str, ty: type[T], default: Any = None
|
||||||
|
) -> Any:
|
||||||
|
"""Fetch ``key`` from ``d`` with an optional default, checking its type.
|
||||||
|
|
||||||
|
When the key is missing (or maps to ``None``) ``default`` is returned. When
|
||||||
|
present, the value must be an instance of ``ty`` or a :class:`TypeError` is
|
||||||
|
raised.
|
||||||
|
|
||||||
|
:param d: Dictionary to read from.
|
||||||
|
:param key: Key to look up.
|
||||||
|
:param ty: Expected type of the value; used for the ``isinstance`` check.
|
||||||
|
:param default: Value returned when the key is absent (defaults to ``None``).
|
||||||
|
:returns: The stored value (typed ``ty``) or ``default``.
|
||||||
|
:raises TypeError: if the stored value is not an instance of ``ty``.
|
||||||
|
"""
|
||||||
tmp = d.get(key, None)
|
tmp = d.get(key, None)
|
||||||
if tmp is None:
|
if tmp is None:
|
||||||
return default
|
return default
|
||||||
@@ -45,6 +73,18 @@ def dict_typed_get[T](d: dict[str, Any], key: str, ty: type[T], default: Any = N
|
|||||||
|
|
||||||
|
|
||||||
def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
|
def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
|
||||||
|
"""Fetch ``key`` from ``d``, requiring it to be present and correctly typed.
|
||||||
|
|
||||||
|
Like :func:`dict_typed_get` but raises :class:`ValueError` when the key is
|
||||||
|
missing (or maps to ``None``) instead of returning a default.
|
||||||
|
|
||||||
|
:param d: Dictionary to read from.
|
||||||
|
:param key: Key to look up.
|
||||||
|
:param ty: Expected type of the value.
|
||||||
|
:returns: The stored value, typed ``ty``.
|
||||||
|
:raises ValueError: if the key is absent.
|
||||||
|
:raises TypeError: if the stored value is not an instance of ``ty``.
|
||||||
|
"""
|
||||||
result = dict_typed_get(d, key, ty)
|
result = dict_typed_get(d, key, ty)
|
||||||
if result is None:
|
if result is None:
|
||||||
raise ValueError(f'can not find key "{key}" in given dictionary')
|
raise ValueError(f'can not find key "{key}" in given dictionary')
|
||||||
@@ -52,14 +92,37 @@ def dict_typed_get_required[T](d: dict[str, Any], key: str, ty: type[T]) -> T:
|
|||||||
|
|
||||||
|
|
||||||
def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]:
|
def dict_chain_get(d: dict[str, Any], *args: str) -> dict[str, Any]:
|
||||||
|
"""Descend through a chain of keys, each required to be a ``dict``.
|
||||||
|
|
||||||
|
Equivalent to calling :func:`dict_typed_get_required` with ``dict`` on each
|
||||||
|
key in turn.
|
||||||
|
|
||||||
|
:param d: Dictionary to start from.
|
||||||
|
:param args: Keys to traverse, in order.
|
||||||
|
:returns: The nested dictionary reached after following every key.
|
||||||
|
"""
|
||||||
for arg in args:
|
for arg in args:
|
||||||
d = dict_typed_get_required(d, arg, dict)
|
d = dict_typed_get_required(d, arg, dict)
|
||||||
return d
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
def list_typed_iter[T](lst: list[Any], ty: type[T]) -> Iterator[T]:
|
||||||
|
"""Yield each element of ``lst`` after checking it is an instance of ``ty``.
|
||||||
|
|
||||||
|
:param lst: The list to iterate.
|
||||||
|
:param ty: Expected type of every element.
|
||||||
|
:returns: An iterator over the validated elements, typed ``T``.
|
||||||
|
:raises TypeError: if an element is not an instance of ``ty``.
|
||||||
|
"""
|
||||||
|
for i, item in enumerate(lst):
|
||||||
|
if not isinstance(item, ty):
|
||||||
|
raise TypeError(f"item {i} of the given list is not a {ty.__name__}")
|
||||||
|
yield item
|
||||||
|
|
||||||
|
|
||||||
_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_+-]+")
|
_NAME_PATTERN: Pattern = compile(r"[a-zA-Z0-9_+-]+")
|
||||||
|
|
||||||
|
|
||||||
def is_good_name(name: str) -> bool:
|
def is_good_name(name: str) -> bool:
|
||||||
"""This package specific name checker
|
"""This package specific name checker
|
||||||
|
|
||||||
@@ -68,3 +131,58 @@ def is_good_name(name: str) -> bool:
|
|||||||
:returns: True if given name is legal, otherwise false.
|
:returns: True if given name is legal, otherwise false.
|
||||||
"""
|
"""
|
||||||
return _NAME_PATTERN.fullmatch(name) is not None
|
return _NAME_PATTERN.fullmatch(name) is not None
|
||||||
|
|
||||||
|
|
||||||
|
_EOL_PATTERN: Pattern = compile(r"[\r\n]")
|
||||||
|
|
||||||
|
|
||||||
|
def is_good_sentence(s: str) -> bool:
|
||||||
|
"""Check whether given string has EOL chars.
|
||||||
|
|
||||||
|
This function is used for checking human-readable name and description,
|
||||||
|
because EOL chars are not allowed in these properties.
|
||||||
|
:returns: True if given string don't contain any EOL chars, otherwise false.
|
||||||
|
"""
|
||||||
|
return _EOL_PATTERN.search(s) is None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Triple:
|
||||||
|
"""A Rust target triple (e.g. ``x86_64-pc-windows-msvc``).
|
||||||
|
|
||||||
|
Decomposes a triple into its ``arch``/``vendor``/``operating_system``/
|
||||||
|
``env`` components. Use :meth:`parse` to build one from a string and
|
||||||
|
``str(triple)`` to reconstruct the canonical dashed form.
|
||||||
|
"""
|
||||||
|
|
||||||
|
architecture: str
|
||||||
|
"""Architecture component (e.g. ``x86_64``, ``aarch64``)."""
|
||||||
|
vendor: str
|
||||||
|
"""Vendor component (e.g. ``pc``, ``unknown``, ``apple``)."""
|
||||||
|
operating_system: str
|
||||||
|
"""Operating-system component (e.g. ``windows``, ``linux``, ``darwin``)."""
|
||||||
|
environment: str | None
|
||||||
|
"""Environment/toolchain component (e.g. ``gnu``, ``msvc``), or ``None`` for
|
||||||
|
3-component triples."""
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
base = f"{self.architecture}-{self.vendor}-{self.operating_system}"
|
||||||
|
return f"{base}-{self.environment}" if self.environment is not None else base
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse(s: str) -> "Triple":
|
||||||
|
"""Parse a triple string into a :class:`Triple`.
|
||||||
|
|
||||||
|
:raises ValueError: if the string has fewer than 3 or more than 4
|
||||||
|
dash-separated components.
|
||||||
|
"""
|
||||||
|
parts = s.split("-")
|
||||||
|
if len(parts) < 3 or len(parts) > 4:
|
||||||
|
raise ValueError(f"invalid target triple: {s!r}")
|
||||||
|
env = parts[3] if len(parts) == 4 else None
|
||||||
|
return Triple(
|
||||||
|
architecture=parts[0],
|
||||||
|
vendor=parts[1],
|
||||||
|
operating_system=parts[2],
|
||||||
|
environment=env,
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user