From e0925c1ce1863d64744ba278ed5728d8ecbf85dc Mon Sep 17 00:00:00 2001 From: Vizonex Date: Tue, 13 Jan 2026 20:05:34 -0600 Subject: [PATCH] finialization before starting first release --- README.md | 98 +++++++++++++++++++++++++++++++++----- cyjs/__init__.pxd | 3 ++ cyjs/__init__.py | 25 +++++++++- cyjs/_cyjs.pxd | 2 +- cyjs/_cyjs.pyi | 76 ++++++++++++++--------------- cyjs/_cyjs.pyx | 20 ++++---- pyproject.toml | 7 ++- tests/conftest.py | 2 + tests/test_cclosure.py | 6 ++- tests/test_eval.py | 1 + tests/test_promise_hook.py | 9 +++- 11 files changed, 182 insertions(+), 67 deletions(-) create mode 100644 cyjs/__init__.pxd diff --git a/README.md b/README.md index 973191a..363cb67 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,92 @@ ECMAScript interpreter for Cython & Python built for - Being tiny and easy to use - Having ECMA6 Support - Having a maintained backend (QuickJS-NG) -- Being a good companion alongside [selectolax](https://github.com/rushter/selectolax). +- Being a good companion alongside [selectolax](https://github.com/rushter/selectolax) or beautiful-soup the choice is yours... +- License friendly, after abandoning pyduktape due to the backend no longer being maintained but also having a pretty poor license all together, It inspired me to try something new for a change that could run newer HTML5 Javascript for any puzzle that is thrown your way. + +## Quick Example + + +```python +from cyjs import Context, Runtime + + +def main(): + # You can also provide a runtime if needed it's usage before making multiple contexts however + # is completely optional + rt = Runtime() + + ctx = Context(rt) + + ctx.eval("function add(a, b){ return a + b; }; globalThis.add = add;") + add_func = ctx.get_global().get("add") + + # 3 + print(add_func(1, 2)) + +if __name__ == "__main__": + main() +``` + +Example of use with external html parser tools. + +```python +from cyjs import Context +from selectolax.lexbor import LexborHTMLParser + +# This example demonstates ways of cracking javascript out of webpages +# with an expernal HTML Parser and cyjs to handle the javascript logic. + +# Know that A True HTML5 Dom-API Might require you to make your own +# functions and imagination but also reverse engineering the target page. + +HTML_PAGE = b""" + + + + + + Document + + +
+ + +
+
+ +
+ + +""" + +def main(): + html = LexborHTMLParser(HTML_PAGE) + captcha = html.css_first("div.captcha > script").text(strip=True) + + fake_solver = Context() + fake_solver.eval(captcha + "\nglobalThis.fake_captcha = fake_captcha;") + result = fake_solver.get_global().invoke("fake_captcha", "123") + # should print + # "123-key" + print(result) + +if __name__ == "__main__": + main() + +``` + +There will be more examples in a future update. + + + -## NOTE -The code is still currently under construction as it's taking me a while to brainstorm how to best approch python to js value -converstions and vice versa. as well as how `Context` varaibles should work. ## Alternative Python Javascript Interpreters @@ -38,6 +119,8 @@ for eatch to ensure it works correctly. I may be uploading this library to pypi - [ ] If anybody finds a smarter approch to anything that has already been written throw me an issue or pull request. +- [ ] Reporting and fixing bugs. + - [ ] JSCFunction I haven't figured out a good solution for this one just yet since I'm trying to limit the number of cdef classes to keep the code small and easy to compile. We need a way to bind an opaque Python Object and trying the old quickjs method seems to trigger crashes (believe me when I say I tried doing that already). - [ ] JSClass cdef class extension that can be subclassed in python and cython along with the hooks for all the JSClassExoticMethods (we need an approch to passing off a cdef class as an opaque value which I have not figured out how to do yet) @@ -45,11 +128,4 @@ for eatch to ensure it works correctly. I may be uploading this library to pypi - [ ] JSClassGCMark Hook - [ ] A safe approch for handling JSClass to python object conversion and vice versa (if possible) -- [ ] A Way to cancel a Promise Object which could lead to the creation of an __aiojs library__ extension built off this library for quickjs with callbacks from Promise to asyncio.Future while taking cancellation into consideration and adding Python Coroutine Support for quickjs-ng to be able figure out how to handle correctly. -- [ ] Maybe an easier way to pass off arguments from quickjs to python functions without making the code feel like we are fluking it. -- [ ] A Way to raise python exceptions from quickjs if possible. -- [ ] Better typehinting would be a bonus if someone could pull that off. -- [ ] A couple examples would be nice. If you need inspiration or an example yt-dlp-ejs might be a good freebie. -- [ ] C Extension Modules from python functions or modules have not been implemented yet due to the same problems as JSCFunction and JSClass being more or less puzzles to bind to python than they need to be. -- [ ] If anybody can figure out a way to make JS Arrays convert to any Python array or List (Preferrably array.array if the JS Array isn't typed but a list if it is typed) Feel free to figure this out for me. diff --git a/cyjs/__init__.pxd b/cyjs/__init__.pxd new file mode 100644 index 0000000..84129e4 --- /dev/null +++ b/cyjs/__init__.pxd @@ -0,0 +1,3 @@ +# cython: freethreading_compatible = True +# cython: language_level = 3 +from ._cyjs cimport Context, Object, Runtime diff --git a/cyjs/__init__.py b/cyjs/__init__.py index 4bd188f..4139ed8 100644 --- a/cyjs/__init__.py +++ b/cyjs/__init__.py @@ -1 +1,24 @@ -from ._cyjs import * # noqa: F403 +from ._cyjs import ( + CancelledError, + Context, + JSError, + JSFunction, + Object, + Promise, + PromiseHookType, + Runtime, +) + +__author__ = "Vizonex" +__version__ = "0.1.0" + +__all__ = ( + "CancelledError", + "Context", + "JSError", + "JSFunction", + "Object", + "Promise", + "PromiseHookType", + "Runtime", +) diff --git a/cyjs/_cyjs.pxd b/cyjs/_cyjs.pxd index f181b25..690612e 100644 --- a/cyjs/_cyjs.pxd +++ b/cyjs/_cyjs.pxd @@ -1,5 +1,5 @@ # cython: freethreading_compatible = True - +# cython: language_level = 3 cimport cython from libc.stdint cimport int64_t diff --git a/cyjs/_cyjs.pyi b/cyjs/_cyjs.pyi index 0b25ae8..964a2ca 100644 --- a/cyjs/_cyjs.pyi +++ b/cyjs/_cyjs.pyi @@ -1,6 +1,6 @@ from collections.abc import Callable from enum import IntEnum -from typing import Any, ParamSpec, TypeVar +from typing import Any, Generic, ParamSpec, TypeVar _P = ParamSpec("_P") _T = TypeVar("_T") @@ -47,9 +47,6 @@ BEFORE: PromiseHookType = ... AFTER: PromiseHookType = ... RESOLVE: PromiseHookType = ... -# @cython.internal -# class PromiseHook: ... - class Runtime: def __init__(self) -> None: ... def compute_memory_usage(self) -> Any: ... @@ -67,7 +64,10 @@ class Runtime: def set_memory_limit(self, limit: int) -> Any: ... def set_max_stack_size(self, max_stack_size: int) -> Any: ... def update_statck_top(self) -> Any: ... - def set_promise_hook(self, func: object) -> object: ... + def set_promise_hook( + self, + func: Callable[["Context", PromiseHookType, Promise, Promise | None], None], + ) -> object: ... class _OView: def __init__(self, obj: Object) -> None: ... @@ -117,7 +117,7 @@ class Object: # equivlent to context.eval_this(self) def eval( self, - code: object, + code: str | bytes | bytearray | memoryview, filename: object = ..., strict: bool = ..., backtrace_barrier: bool = ..., @@ -128,7 +128,7 @@ class Object: def eval_module( self, - code: object, + code: str | bytes | bytearray | memoryview, filename: object = ..., strict: bool = ..., backtrace_barrier: bool = ..., @@ -137,10 +137,10 @@ class Object: """evaluates javascript module code""" ... -class JSFunction(Callable[_P, _T]): +class JSFunction(Generic[_P, _T]): context: Context - def __call__(self, *args:_P.args, **kwargs:_P.kwargs) -> _T: ... + def __call__(self, *args: _P.args, **kwargs: _P.kwargs) -> _T: ... @property def object(self) -> Object: pass @@ -164,10 +164,9 @@ class Context: dom_exception: bool = ..., promise: bool = ..., ) -> None: ... - def eval( self, - code: object, + code: str | bytes, filename: object = ..., strict: bool = ..., backtrace_barrier: bool = ..., @@ -178,7 +177,7 @@ class Context: def eval_module( self, - code: object, + code: str | bytes, filename: object = ..., strict: bool = ..., backtrace_barrier: bool = ..., @@ -186,56 +185,53 @@ class Context: ) -> object: """evaluates javascript module code""" ... - - def get_global(self) -> Object: - ... - def json_parse(self, json: object) -> Object: - ... + def get_global(self) -> Object: ... + def json_parse(self, json: object) -> Object: ... def get(self, name: bytes | str) -> Object | Any: - """Implements a Shortcut for converting a global object to a python + """Implements a Shortcut for converting a global object to a python object and setting a value to utilize off of.""" ... - def set(self, name: bytes | str, item: object) -> None: + def set( + self, name: bytes | str, item: object | Object | Promise | JSFunction + ) -> None: """Sets an item to the current globalThis object""" ... def add_function( - self, func: Callable[...], name: str | bytes | None = None, magic: int = 11 - ) -> JSFunction: - """adds a python function to quickjs using - `JS_NewCClosure` since Quickjs-ng doesn't have + self, func: Callable[_P, _T], name: str | bytes | None = None, magic: int = 11 + ) -> JSFunction[_P, _T]: + """adds a python function to quickjs using + `JS_NewCClosure` since Quickjs-ng doesn't have a good way for bidning python fucntions well yet - - :param func: the python function to invoke with + + :param func: the python function to invoke with quickjs note: that it may not pass along keyword arguments `**kw` :param name: an alternative name to give to the function being passed - :param magic: the magic value of the js function (Not much is known about it + :param magic: the magic value of the js function (Not much is known about it at the moment... defaults to 11 which reflects quickjs's own tests) """ - + def eval_this( self, - code: object, + code: str | bytes | bytearray | memoryview, this: Object | Any, filename: object = ..., strict: bool = ..., backtrace_barrier: bool = ..., promise: bool = ..., - ) -> Any:... - + ) -> Any: ... def eval_this_with_module( - self, - code: object, + self, + code: str | bytes | bytearray | memoryview, this: Object | Any, filename: object = ..., strict: bool = ..., backtrace_barrier: bool = ..., promise: bool = ..., - ) -> Any:... - + ) -> Any: ... class CancelledError(Exception): """Promise was rejected""" @@ -248,23 +244,21 @@ class InvalidStateError(Exception): ... class Promise(Object): - def add_done_callback(self, fn: object) -> object: + def add_done_callback(self, fn: Callable[["Promise"], None]) -> object: """Attaches a callable callback when promise finishes or raises an exception""" ... - def exception(self) -> object: ... + def exception(self) -> BaseException: ... def done(self) -> bool: ... - def remove_done_callback(self, fn: object) -> int: + def remove_done_callback(self, fn: Callable[["Promise"], None]) -> int: """Remove all instances of a callback from the "call when done" list. Returns the number of callbacks removed. """ ... - def result(self) -> object: ... - def poll(self) -> object: + def result(self) -> Any: ... + def poll(self) -> Any: """Polls QuickJS Eventloop a single cycle while attempting to wait for this Promise to complete""" ... - - diff --git a/cyjs/_cyjs.pyx b/cyjs/_cyjs.pyx index 9218a85..28770c2 100644 --- a/cyjs/_cyjs.pyx +++ b/cyjs/_cyjs.pyx @@ -1,16 +1,18 @@ # cython: freethreading_compatible = True -from .quickjs cimport * -from cpython.bytes cimport PyBytes_FromStringAndSize -from cpython.unicode cimport PyUnicode_FromString, PyUnicode_FromStringAndSize -from cpython.exc cimport PyErr_NoMemory, PyErr_SetObject, PyErr_WriteUnraisable -from cpython.mem cimport PyMem_Malloc, PyMem_Realloc, PyMem_Free -from cpython.long cimport PyLong_FromString, PyLong_AsLongAndOverflow from cpython.bool cimport PyBool_FromLong from cpython.buffer cimport PyObject_CheckBuffer -from cpython.tuple cimport PyTuple_GET_SIZE +from cpython.bytes cimport PyBytes_FromStringAndSize +from cpython.exc cimport (PyErr_CheckSignals, PyErr_NoMemory, PyErr_Occurred, + PyErr_SetObject, PyErr_WriteUnraisable) from cpython.list cimport PyList_AsTuple +from cpython.long cimport PyLong_AsLongAndOverflow, PyLong_FromString +from cpython.mem cimport PyMem_Free, PyMem_Malloc, PyMem_Realloc from cpython.object cimport PyObject_CallObject, PyObject_Str -from cpython.exc cimport PyErr_CheckSignals, PyErr_Occurred +from cpython.tuple cimport PyTuple_GET_SIZE +from cpython.unicode cimport PyUnicode_FromString, PyUnicode_FromStringAndSize + +from .quickjs cimport * + cdef extern from "Python.h": # tweaked signature just a little for our purposes... @@ -24,8 +26,10 @@ cdef extern from "Python.h": cimport cython # TODO: I'm writing a new cython utils library and a yyjson writer would be a good use-case here... + import json + cdef class JSError(Exception): """Represents Numerous Exceptions raised from CYJS""" diff --git a/pyproject.toml b/pyproject.toml index 82b8f7c..0371086 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [project] name = "cyjs" -version = "0.1.0" -description = "Add your description here" +dynamic = ["version"] +description = "A Next Generation QuickJS Intrepreter for cython & python" readme = "README.md" authors = [ { name = "Vizonex", email = "VizonexBusiness@gmail.com" } @@ -21,3 +21,6 @@ requires = ["setuptools", "cython", "wheel"] [tool.setuptools] zip-safe = false packages = ['cyjs'] + +[tool.setuptools.dynamic] +version = {attr = "cyjs.__version__"} diff --git a/tests/conftest.py b/tests/conftest.py index 5f6d808..47d1b42 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,10 +2,12 @@ import pytest + @pytest.fixture() def rt(): return Runtime() + @pytest.fixture(scope="function", autouse=True) def ctx() -> Context: return Context() diff --git a/tests/test_cclosure.py b/tests/test_cclosure.py index 1223875..11681c6 100644 --- a/tests/test_cclosure.py +++ b/tests/test_cclosure.py @@ -2,10 +2,12 @@ import pytest + # oldest trick in the book... -def add(a: int, b:int) -> int: +def add(a: int, b: int) -> int: return a + b + # TODO: # def peform_raise(): # raise RuntimeError("I'm a teapot") @@ -17,9 +19,11 @@ def test_cclosure_eval(ctx: Context) -> None: result = ctx.eval("globalThis.add(1, 2)") assert result == 3 + def throw_exception(): raise RuntimeError("Boo") + def test_function_that_raises_exception(ctx: Context): func = ctx.add_function(throw_exception, "py_throw_exception") ctx.set("py_throw", func) diff --git a/tests/test_eval.py b/tests/test_eval.py index afe29a8..abfbba3 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -1,5 +1,6 @@ from cyjs import Context + def test_global_eval(ctx: Context) -> None: ctx.eval('globalThis.x = "Vizonex";', strict=True) assert ctx.get_global().get("x") == "Vizonex" diff --git a/tests/test_promise_hook.py b/tests/test_promise_hook.py index f3150e0..dc8661a 100644 --- a/tests/test_promise_hook.py +++ b/tests/test_promise_hook.py @@ -17,7 +17,11 @@ class Counter: resolve: int = 0 def on_promise( - self, ctx: Context, hook: PromiseHookType, promise: Promise, parent: object + self, + ctx: Context, + hook: PromiseHookType, + promise: Promise, + parent: Promise | None, ): assert isinstance(promise, Promise), "promise was not actually a promise :(" match hook: @@ -82,13 +86,14 @@ def test_bad_hook(rt: Runtime) -> None: with pytest.raises(TypeError): rt.set_promise_hook(None) + def callback_failure(*args): raise RuntimeError("Jumpscare!") + def test_callback_failure(rt: Runtime) -> None: ctx = Context(rt) rt.set_promise_hook(callback_failure) with pytest.raises(RuntimeError): ctx.eval_module("new Promise(() => {})") -