Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/plugins #56

Merged
merged 1 commit into from
Apr 13, 2024
Merged

Feature/plugins #56

merged 1 commit into from
Apr 13, 2024

Conversation

vikanezrimaya
Copy link
Contributor

@vikanezrimaya vikanezrimaya commented Jan 26, 2024

API surface exposed is equivalent to the Rust API, including plugin callbacks (exposed as pyrage.plugin.Callbacks, which must be subclassed for anything interesting to happen).

Non-Cloneability of plugin instances is resolved by putting them behind an Arc. Perhaps a more elegant solution could be produced later, by someone with more knowledge of PyO3 internals.

This was tested with rage's example age-unencrypted-plugin which happens to exercise the display_message callback, and additionally with age-plugin-tpm to exercise actual encryption functionality.

Typing stubs are amended to cover the new API surface.

Resolves #55.

EDIT 2024-03-29: now based on #63, which should be merged first.

Cargo.lock Outdated Show resolved Hide resolved
src/plugin.rs Outdated Show resolved Hide resolved
src/plugin.rs Outdated
Comment on lines 12 to 19
/// Hack, because the orphan rule would prevent us from deriving a
/// foreign trait on a foreign object. Instead, define a newtype.
#[derive(Clone)]
pub(crate) struct PyCallbacks(Py<Callbacks>);

#[pymethods]
#[allow(unused_variables)]
impl Callbacks {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just leaving a note: I need to think about this a bit. Rather than messing around with subclassing like this, we may just want the newtype to be over Py<PyAny> and do structural subtyping instead (since we get relatively little from the concrete base here).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What a coincidence, I also entertained the thought of just duck-typing everything, but I guess I'm a bit rusty (pardon the pun) when it comes to proper types.

I suppose I could rewrite this bit to duck-type on Rust side, and add an interface class to the Python type stubs so that the Python side can still type-check the usage of this API.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I suppose I could rewrite this bit to duck-type on Rust side, and add an interface class to the Python type stubs so that the Python side can still type-check the usage of this API.

Yeah, this sounds good to me -- I think the performance will be identical, and will be a bit easier to reason about/read here 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rewrote to duck-type and use a protocol. I still explicitly check on construction of the PyCallbacks wrapper, but perhaps this check could be omitted in lieu of static type-checking (which I checked is working properly!)

@vikanezrimaya vikanezrimaya force-pushed the feature/plugins branch 2 times, most recently from b7f297d to b6ba84f Compare January 29, 2024 15:27
@vikanezrimaya
Copy link
Contributor Author

Example program that passes the type check and runs successfully:

import pyrage
import typing

class Callbacks:
    def display_message(self, message: str) -> None:
        print(message)

    def confirm(
            self,
            message: str,
            yes_string: str,
            no_string: str | None
    ) -> typing.Optional[bool]:
        print(message)
        if no_string is not None:
            prompt = f"{yes_string}/{no_string}? [y/n]"
        else:
            prompt = f"{yes_string}? [y/n]"
        
        i = None
        while True:
            i = input(prompt)
            if len(i) < 1:
                i = None
                continue
            if i[0] in ["y", "Y"]:
                return True
            if i[0] in ["n", "N"]:
                return False

    def request_public_string(self, description: str) -> typing.Optional[str]:
        i = input(f"{description}: ")
        if i == "":
            return None
        return i

    # XXX use obscured input
    def request_passphrase(self, description: str) -> typing.Optional[str]:
        i = input(f"(passphrase) {description}: ")
        if i == "":
            return None
        return i

with open("/home/vika/age-tpm.txt", "r") as f:
    identity = pyrage.plugin.Identity.from_str(f.read().strip())
    recipient = pyrage.plugin.Recipient.from_str(
        "age1tpm1qv75z0j9pkefmp0etdmcqapxdm9wzze4wl3j5zjn66ksung85wvvchxel2y"
    )

unencrypted_identity = pyrage.plugin.Identity.default_for_plugin("unencrypted")

recipient_unencrypted_plugin = pyrage.plugin.RecipientPluginV1(
    unencrypted_identity.plugin(), [], [unencrypted_identity], Callbacks()
)
identity_unencrypted_plugin = pyrage.plugin.IdentityPluginV1(
    unencrypted_identity.plugin(), [unencrypted_identity], Callbacks()
)

identity_tpm_plugin = pyrage.plugin.IdentityPluginV1(
    identity.plugin(),
    [identity],
    Callbacks()
)
recipient_tpm_plugin = pyrage.plugin.RecipientPluginV1(
    identity.plugin(),
    [recipient],
    [identity],
    Callbacks()
)

assert(pyrage.decrypt(
    pyrage.encrypt(b"test", [recipient_unencrypted_plugin, recipient_tpm_plugin]),
    [identity_unencrypted_plugin, identity_tpm_plugin]
) == b"test")

Output:

$ maturin build && pip install --force-reinstall target/wheels/pyrage-1.1.3rc1-cp38-abi3-manylinux_2_34_x86_64.whl ./pyrage-stubs && PYTHONPATH=venv/lib/python3.11/site-packages:$PYTHONPATH mypy --strict ./test_plugin.py && PATH=$PWD/../rage/target/debug/examples:$PATH python3 test_plugin.py
⚠️  Warning: You specified maturin >=0.14, <0.15 in pyproject.toml under `build-system.requires`, but the current maturin version is 1.4.0
📦 Including license file "/home/vika/nyantec/Projects/operations/pyrage/LICENSE"
🔗 Found pyo3 bindings with abi3 support for Python ≥ 3.8
🐍 Not using a specific python interpreter
warning: unused import: `exceptions::PyAttributeError`
 --> src/plugin.rs:4:12
  |
4 | use pyo3::{exceptions::PyAttributeError, prelude::*, types::PyType};
  |            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  |
  = note: `#[warn(unused_imports)]` on by default

warning: `pyrage` (lib) generated 1 warning (run `cargo fix --lib -p pyrage` to apply 1 suggestion)
    Finished dev [unoptimized + debuginfo] target(s) in 0.06s
📦 Built wheel for abi3 Python ≥ 3.8 to /home/vika/nyantec/Projects/operations/pyrage/target/wheels/pyrage-1.1.3rc1-cp38-abi3-manylinux_2_34_x86_64.whl
Processing ./target/wheels/pyrage-1.1.3rc1-cp38-abi3-manylinux_2_34_x86_64.whl
Processing ./pyrage-stubs
  Installing build dependencies ... done
  Getting requirements to build wheel ... done
  Installing backend dependencies ... done
  Preparing metadata (pyproject.toml) ... done
Building wheels for collected packages: pyrage-stubs
  Building wheel for pyrage-stubs (pyproject.toml) ... done
  Created wheel for pyrage-stubs: filename=pyrage_stubs-1.1.0-py3-none-any.whl size=5023 sha256=5a057ebf600dae4b66305148572dbd1fcdacb15572506c8f058bdfa2f11fe55f
  Stored in directory: /home/vika/.cache/pip/wheels/9b/4f/68/e49c2c73e68d037f825b754a56f168d824899d18d730deb26c
Successfully built pyrage-stubs
Installing collected packages: pyrage, pyrage-stubs
  Attempting uninstall: pyrage
    Found existing installation: pyrage 1.1.3rc1
    Uninstalling pyrage-1.1.3rc1:
      Successfully uninstalled pyrage-1.1.3rc1
  Attempting uninstall: pyrage-stubs
    Found existing installation: pyrage-stubs 1.1.0
    Uninstalling pyrage-stubs-1.1.0:
      Successfully uninstalled pyrage-stubs-1.1.0
Successfully installed pyrage-1.1.3rc1 pyrage-stubs-1.1.0

[notice] A new release of pip is available: 23.2.1 -> 23.3.2
[notice] To update, run: pip install --upgrade pip
Success: no issues found in 1 source file
This plugin doesn't have any recipient-specific logic. It's unencrypted!
This identity does nothing!

@woodruffw
Copy link
Owner

Nice work! I'll do another round of review tonight.

src/plugin.rs Outdated Show resolved Hide resolved
@woodruffw
Copy link
Owner

This is looking pretty good, thank you @vikanezrimaya! If you could give age_plugin a try that would be fantastic (and if it doesn't work we can go ahead with the current approach).

Finally, it'd be good to have 1-2 tests in this PR before merge. Nothing huge; just something small like the sample you posted above.

@vikanezrimaya
Copy link
Contributor Author

I'm not really sure how to test it, given it inherently depends on external binaries. This will need research.

@vikanezrimaya
Copy link
Contributor Author

I am terribly sorry for piling up so many unrelated things into this merge request, but I keep finding small things that can be fixed with just a few lines of code, and I'm also using this package for internal tooling at my workplace, so I keep stumbling into things that need to be fixed all the time! If you wish, I could probably split a few commits into separate merge requests so we can merge them independently of everything else.

@woodruffw
Copy link
Owner

Sorry, I've been a big backlogged on reviews. Taking a look again today!

@vikanezrimaya
Copy link
Contributor Author

I split off unrelated type-stub fixes into #63 (which was based on top of latest main) and rebased this branch on top of #63. Merging that would push these small fixes faster into the main branch, while I can spend more time musing on how best to create tests for the plugin functionality (sadly, I didn't have too much time to think on that, because of my $dayjob responsibilities)

@woodruffw
Copy link
Owner

Sorry for the hassle here -- I think you'll need to deconflict now that pyo3 has updated to use the Bound<...> APIs.

@vikanezrimaya vikanezrimaya force-pushed the feature/plugins branch 2 times, most recently from 1c8895d to 276713f Compare April 5, 2024 15:23
@vikanezrimaya
Copy link
Contributor Author

Thanks for the ping! That Bound API sure looks nice — I rewrote the plugin code to use it where possible, so we get no deprecation warnings and nicer code in some places.

src/plugin.rs Outdated Show resolved Hide resolved
API surface exposed is equivalent to the Rust API, including plugin
callbacks (duck-typed at runtime, exposed in type stubs as a
`pyrage.plugin.Callbacks` protocol, all of which's methods are
required to be implemented at runtime, otherwise `AttributeError` is
thrown).

Non-`Clone`ability of plugin instances is resolved by putting them
behind an `Arc`. Perhaps a more elegant solution could be produced
later, by someone with more knowledge of PyO3 internals.

This was tested with rage's example `age-unencrypted-plugin` which
happens to exercise the `display_message` callback, and additionally
with `age-plugin-tpm` to exercise actual encryption functionality.

Typing stubs are amended to cover the new API surface. Conformance to
type-stubs was checked by writing a short Python program exercising
the functionality and type-checking it using `mypy` with the type
stubs installed.
Copy link
Owner

@woodruffw woodruffw left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for your patience here @vikanezrimaya!

@woodruffw woodruffw merged commit eac37b3 into woodruffw:main Apr 13, 2024
17 checks passed
@woodruffw
Copy link
Owner

P.S. let me know if you'd like a release with these new APIs. Otherwise I'll likely do it sometime later this month.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Support age plugins
2 participants