Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python 개선 제안 한국어 번역

PEP 818 – Pyodide 외부 함수 인터페이스의 핵심을 Python에 추가하기

Author:
Hood Chatham <roberthoodchatham at gmail.com>
Sponsor:
Łukasz Langa <lukasz at python.org>
Discussions-To:
Discourse thread
Status:
Draft
Type:
Standards Track
Created:
10-Dec-2025
Python-Version:
3.15

Table of Contents

번역·라이선스 안내

이 비공식 한국어 번역은 원문 Copyright 절의 Public Domain or CC0-1.0, whichever is more permissive 조건에 따라 제공합니다. 원저자와 공식 원문은 그대로 표시합니다. 수정되지 않은 기준 원문 · 공식 최신판

초록

Pyodide는 브라우저를 비롯한 JavaScript 런타임을 위한 Python 배포판입니다. 브라우저는 범용 컴퓨팅 플랫폼입니다. Unix 계열 운영 체제에서 C가 그러하듯이, 브라우저 플랫폼에서는 모든 기본 기능이 JavaScript 언어를 통해 노출됩니다. Pyodide는 수년간 포괄적인 JavaScript 외부 함수 인터페이스를 포함해 왔습니다. 이는 JavaScript 세계에서 os 모듈에 해당하는 기능을 제공합니다.

이 PEP는 Pyodide 외부 함수 인터페이스의 핵심을 Python에 추가할 것을 제안합니다.

동기

Pyodide 프로젝트는 JavaScript 런타임을 위한 Python 배포판입니다. Pyodide는 매우 인기 있는 프로젝트입니다. 2025년에 Pyodide는 JsDelivr에서 10억 건이 넘는 요청을 받았습니다. 인기는 빠르게 높아지고 있습니다. 지난 2년 동안 해마다 사용량이 두 배 이상 늘었습니다.

Pyodide는 다음과 같은 여러 구성 요소를 포함합니다:

  1. Emscripten 컴파일러 툴체인(linux C/C++ 프로그램을 JavaScript와 WebAssembly로 컴파일하는 툴체인)으로 이식한 CPython 포트.
  2. JavaScript에서 Python을 호출하고 Python에서 JavaScript를 호출하기 위한 외부 함수 인터페이스.
  3. Python 런타임과 패키지 설치를 관리하기 위한 JavaScript 프로그래밍 인터페이스.
  4. 네이티브 확장을 위한 ABI.
  5. Pyodide에서 사용할 수 있도록 해당 ABI와 호환되는 Python 패키지를 크로스 컴파일하는 툴체인.

장기적으로 저희는 Pyodide 프로젝트의 런타임 구성 요소 (1)–(4)를 CPython에 업스트림하고자 합니다. 2022년에 Christian Heimes가 (1) CPython의 Emscripten 포트를 업스트림했으며, 현재 Emscripten은 티어 3 지원 플랫폼입니다(PEP 776 참조). PEP 783은 Pyodide 호환 휠을 PyPI에 업로드할 수 있도록 허용할 것을 제안합니다. 이 휠이 Emscripten-CPython 호환 휠이 되려면 (2) Python/JavaScript 외부 함수 인터페이스와 (4) 네이티브 확장을 위한 ABI를 업스트림해야 합니다. 이 PEP는 (2) Python/JavaScript 외부 함수 인터페이스를 부분적으로 업스트림하는 일을 다룹니다.

이 인터페이스는 linux에서 Python의 os 모듈과 비슷합니다. 모든 IO는 libc를 거쳐야 하며, os 모듈은 Python 코드가 libc 호출에 접근할 수 있게 해 줍니다. 마찬가지로 JavaScript 런타임에서 실제 작업을 하려면 JavaScript를 호출해야 합니다. 예를 들어 화면에 콘텐츠를 표시하거나, 사용자 입력을 받거나, 이벤트를 처리하거나, 데이터베이스에 접근하는 등의 작업에는 JavaScript 호출이 필요합니다. 예컨대 Python에 JavaScript 외부 함수 인터페이스가 생기면 Emscripten에서 urllib을 지원할 수 있게 됩니다. 다운스트림에서 urllib3, aiohttp, httpx를 지원하려면 외부 함수 인터페이스가 필요합니다.

이 PEP의 분량을 적절하게 유지하기 위해 외부 함수 인터페이스의 “핵심”에 집중합니다. 다음 세 가지 영역은 향후 PEP로 남겨 둡니다:

  1. asyncio
  2. 버퍼 프로토콜과 이에 대응하는 JavaScript 기능 사이의 통합
  3. Python 런타임을 관리하기 위한 JavaScript 인터페이스

근거

이 문서의 목표는 Pyodide의 외부 함수 인터페이스를 업스트림에 반영하는 것입니다. 다만 Pyodide의 많은 기존 사용자를 고려하여 하위 호환성은 꼭 필요한 만큼만 깨뜨리고자 합니다. 한편, 호환성을 깨뜨리는 변경을 하기에 가장 좋은 때는 바로 지금입니다.

이를 염두에 두고, 여기서는 우리의 설계가 완벽하다는 것이 아니라 어떤 변경이든 그 비용이 이점보다 크다는 것을 입증하고자 합니다.

객체 변환

가장 근본적인 결정은 한 언어의 객체를 다른 언어로 어떻게 변환하느냐입니다. 객체를 변환할 때는 값을 대상 언어의 유사한 객체로 변환하는 방법과 원래 객체를 “감싸는” 프록시를 만드는 방법 중 하나를 선택할 수 있습니다. 여기에는 몇 가지 고려 사항이 있습니다:

  1. 가변성: 인자를 변경할 것으로 예상되는 함수를 호출하는 경우, 인자를 변환하지 않고 프록시하는 것이 중요합니다. 그렇지 않으면 함수는 결국 버려질 복사본을 변경하게 됩니다. 따라서 암묵적 변환은 불변 객체에 대해서만 합리적인 선택지입니다.
  2. 왕복 동작: 객체를 Python에서 JavaScript로 전달했다가 다시 Python으로 가져오면 원래의 Python 객체가 되고, 그 반대의 경우도 마찬가지인 것이 매우 바람직합니다. 객체가 불변이라면, 결과가 원래 객체와 같은 객체가 아니라 동등하기만 해도 괜찮습니다. 객체가 가변이라면 같은 객체여야 합니다.
  3. 성능 특성: 복잡한 객체를 변환하려면 사전에 많은 작업이 필요합니다. 객체를 아주 적게 사용한다면 성능이 더 떨어질 수 있습니다. 반면에 프록시를 통해 객체에 접근할 때마다 네이티브 객체에 접근하는 것보다 느리므로, 객체를 많이 사용한다면 사전에 변환하는 것이 프록시하는 것보다 더 효율적입니다. 기본적으로 프록시하고 사용자가 원할 때 명시적으로 변환할 수 있게 하면 사용자가 성능을 최대한 제어할 수 있습니다.
  4. 사용 편의성: 많은 경우 네이티브 객체가 다루기 더 쉽습니다.

JavaScript에는 다음과 같은 불변 타입이 있습니다: string, undefined, boolean, number, bigint. 또한 특수한 값 null도 있습니다.

이 중 string과 boolean은 각각 str과 bool에 직접 대응합니다. Number.isSafeInteger()가 true를 반환하면 number를 int로 변환하고, 그렇지 않으면 float로 변환합니다. 반대로 float는 number로 변환하고, int는 2**53을 넘지 않으면 number로, 넘으면 bigint로 변환합니다. bigint의 변환 결과로 사용할 JSBigInt라는 int의 새 서브클래스를 만듭니다. undefined는 누락된 인자의 기본값이므로 None에 대응합니다. null의 변환 결과로 사용하기 위해 JSNull 타입의 새로운 거짓 같은(falsey) 싱글턴 Python 값 jsnull을 고안합니다. 그 밖의 모든 타입은 프록시됩니다.

특히 tuples는 불변이지만 JavaScript에 대응하는 것이 없으므로 프록시합니다. 원한다면 toJs() 메서드로 Array로 수동 변환할 수 있습니다.

프록시

JSProxy는 JavaScript 객체에 접근하는 데 사용되는 Python 객체입니다. JSProxy가 존재하는 동안, 기반 JavaScript 객체는 테이블에 보관되어 가비지 컬렉션되지 않습니다.

PyProxy는 Python 객체에 접근하는 데 사용되는 JavaScript 객체입니다. PyProxy가 생성되면 하위 Python 객체의 참조 횟수가 증가합니다. .destroy() 메서드가 호출되면 하위 Python 객체의 참조 횟수가 감소하고 프록시가 비활성화됩니다. 이후에 프락시를 사용하려고 하면 오류가 발생합니다.

기본 JSProxy는 프로퍼티 접근, 동등성 검사, __repr__, __eq__, __bool__과 그 밖의 몇 가지 편의 메서드를 구현합니다. 또한 추상 Python 객체 프로토콜을 추상 JavaScript 객체 프로토콜에 매핑하여(그리고 그 반대로도) 많은 수의 믹스인을 정의합니다. 이 PEP에서 설명하는 매핑은 다음과 같습니다:

기본 프락시(모든 객체에 공통인 프로퍼티):

  • __getattribute__ <==> Reflect.get (프락시 핸들러)
  • __setattr__ <==> Reflect.set (프락시 핸들러)
  • __eq__ <==> === (객체 동일성)
  • __repr__ <==> toString

__str__ 구현으로는 __repr__를 사용하는 기본 구현을 상속합니다.

다음과 같은 프로토콜 간 매핑을 믹스인으로 구현합니다. 프락시를 생성할 때 프락시가 이러한 추상 프로토콜과 구체 프로토콜 중 어느 것을 지원하는지 기능 탐지하고, 적절한 믹스인을 갖춘 프락시용 클래스를 생성합니다.

  • __iter__ <==> [Symbol.iterator]
  • __next__ <==> next
  • __len__ <==> length, size
  • __getitem__ <==> get
  • __setitem__, __delitem__ <==> set, delete
  • __contains__ <==> includes, has
  • __call__ <==> Reflect.apply (프락시 핸들러)
  • Generator <==> Generator
  • Exception <==> Error
  • MutableSequence <==> Array

JavaScript 객체에 [Symbol.dispose]() 메서드가 있으면 Python 객체를 컨텍스트 관리자로 만들지만, 현재로서는 [Symbol.dispose]()를 구현하는 데 컨텍스트 관리자를 사용하지 않습니다.

JavaScript에는 Reflect.construct(new 키워드)도 있습니다. 호출 가능 객체인 JSProxy에는 Reflect.construct에 대응하는 new()라는 메서드가 있습니다.

다음 추가 매핑은 Pyodide에 정의되어 있습니다. 최종적으로는 이것들을 Python 자체에 추가하려는 것이 저희의 의도이지만, 향후 PEP로 미뤄 두었습니다:

  • __await__ <==> then
  • __aiter__ <==> [Symbol.asyncIterator]
  • __anext__ <==> next (__next__와 같습니다. 구별하려면 [Symbol.asyncIterator]가 있는지 확인하십시오)
  • AsyncGenerator <==> AsyncGenerator
  • 버퍼 프로토콜 <==> 형식화 배열(typed arrays)
  • 비동기 컨텍스트 관리자는 [Symbol.asyncDispose]를 구현하는 JSProxy에 구현됩니다.

프록시의 가비지 컬렉션과 파괴

우리가 직면한 가장 근본적인 어려움은 Python 가비지 컬렉터와 JavaScript 가비지 컬렉터라는 두 개의 가비지 컬렉터가 존재한다는 점입니다. Python에서 JavaScript를 거쳐 다시 Python으로 돌아오는 참조 순환은 모두 누수됩니다. 게다가 순환이 없더라도 JavaScript 가비지 컬렉터는 PyProxy가 얼마나 많은 메모리를 소유하는지, Python 가비지 컬렉터가 얼마나 큰 메모리 압박을 받는지 전혀 알지 못합니다.

이러한 이유로 언어 간의 참조를 수동으로 끊는 방법을 포함해야 합니다. Python에서는 객체의 참조 횟수가 0에 도달하면 소멸자가 즉시 실행됩니다. 따라서 프로그래머가 JavaScript 객체를 수동으로 해제하려면 그 객체에 대한 모든 참조를 삭제하면 되며, 그 후에는 JavaScript 가비지 컬렉터가 해당 객체를 회수할 수 있습니다.

반면에 JavaScript 파이널라이저는 신뢰할 수 없습니다. 파이널라이저를 언어에 도입한 제안서에는 다음과 같이 적혀 있습니다.

애플리케이션이나 라이브러리가 GC가 시기적절하고 예측 가능한 방식으로 [파이널라이저를 호출하는] 것에 의존한다면 실망하게 될 가능성이 높습니다. 정리가 예상보다 훨씬 늦게 일어나거나 아예 일어나지 않을 수도 있습니다.

…

[파이널라이저]는 외부 자원을 정리하는 일반적인 방법이 아니라, 과도한 메모리 사용을 피하는 방법이나 특정 버그에 대비한 안전장치로 사용하는 것이 가장 좋습니다.

https://github.com/tc39/proposal-weakrefs?tab=readme-ov-file#a-note-of-caution

PyProxy에는 PyProxy를 수동으로 분리하고 Python 참조를 해제하는 destroy() 메서드가 있습니다. 우리는 PyProxy를 파괴하는 것이 이를 정리하는 올바르고 일반적인 방법이라고 봅니다. 제안서에서 권장하는 대로 파이널라이저는 안전장치로 취급합니다. Pyodide 테스트 스위트에서는 대부분의 테스트에서 모든 PyProxy를 수동으로 파괴하도록 요구합니다. 이는 이러한 방식을 편리하게 유지할 수 있도록 API가 설계되게 하는 데 도움이 됩니다.

호출 규약

JavaScript에서 Python 함수 호출하기

호출 가능 객체인 PyProxy를 호출하려면 다음 단계를 수행합니다.

  1. 각 인자를 JavaScript에서 Python으로 변환하고 인자들을 C 배열에 넣습니다.
  2. PyObject_VectorCall을 사용하여 Python 객체를 호출합니다.
  3. JavaScript 오류가 발생하면 이는 치명적입니다. Python 인터프리터의 불변 조건이 위반된 것입니다. 치명적 오류를 보고하고 Python 인터프리터를 종료합니다.
  4. Python 오류 플래그가 설정되어 있으면 sys.last_value를 현재 예외로 설정합니다. Python 예외를 JavaScript PythonError 객체로 변환합니다. 이 PythonError 객체는 Python 예외의 타입, 서식화된 트레이스백, 그리고 원래 Python 예외에 대한 약한 참조를 기록합니다. 이 PythonError를 던집니다.
  5. 결과를 Python에서 JavaScript로 변환하여 반환합니다.

여기서 JSProxy가 생성되었지만 Python 함수가 그에 대한 참조를 저장하지 않으면 즉시 해제된다는 점에 유의하십시오. JavaScript 오류는 Python 예외에 대한 강한 참조를 보유하지 않습니다. JavaScript 오류는 누수되는 경우가 많고, Python 오류 객체는 상당한 양의 메모리를 차지할 수 있는 프레임 객체에 대한 참조를 보유하기 때문입니다.

Python에서 JavaScript 함수 호출하기

호출 가능 객체인 JSProxy를 호출하려면 다음 단계를 수행합니다.

  1. pyproxies라는 빈 배열을 만듭니다
  2. 각 위치 인자를 Python에서 JavaScript로 변환하고, 이 인자들을 jsargs라는 JavaScript 배열에 넣습니다. 이 과정에서 PyProxy가 생성되면, 그에 대한 JavaScript 파이널라이저는 등록하지 말고 pyproxies에 추가합니다.
  3. 키워드 인자가 있으면 빈 JavaScript 객체 jskwargs를 만들고, 각 키워드 인자를 JavaScript로 변환하여 jskwargs[key] = jskwarg로 할당합니다. jskwargs를 jsargs에 추가합니다. 이 과정에서 PyProxy가 생성되면, 그에 대한 JavaScript 파이널라이저는 등록하지 말고 pyproxies에 추가합니다.
  4. JavaScript 함수를 호출하고 결과를 jsresult에 저장합니다.
  5. 오류가 발생하면 다음과 같이 처리합니다.
    1. 오류가 PythonError이고 Python 예외에 대한 약한 참조가 아직 살아 있다면, 참조된 Python 예외를 발생시킵니다.
    2. 그렇지 않다면, 예외를 JavaScript에서 Python으로 변환하고 그 결과를 발생시킵니다. JSException 객체는 원래의 JavaScript 오류에 대한 참조를 보유한다는 점에 유의하십시오.
  6. jsresult가 JavaScript 제너레이터이면, pyproxies를 순회하며 각각에 대해 JavaScript 파이널라이저를 등록합니다. 제너레이터가 소진되면 pyproxies를 파괴하는 새 제너레이터로 해당 제너레이터를 감쌉니다. 감싼 제너레이터를 Python으로 변환하여 반환합니다.
  7. 그렇지 않으면, jsresult를 Python으로 변환하여 pyresult에 저장합니다.
  8. pyproxies를 순회하며 파괴합니다. jsresult가 PyProxy이면 그것도 파괴합니다.
  9. pyresult를 반환합니다.

이는 C Python API의 호출 규약을 본뜬 것입니다.

JSProxy 호출 규약에 대한 변론

JavaScript에서 Python으로의 호출 규약은 논란의 여지가 없으므로 이에 대해서는 변론하지 않겠습니다. Python에서 JavaScript로의 호출 규약은 논란의 여지가 더 크므로, 이것이 대안들보다 더 나은 설계라고 생각하는 이유를 여기에서 설명하겠습니다.

이 설계의 주된 단점은 피호출자가 인자를 계속 보관하려는 경우에 사용하기가 그다지 편하지 않다는 점입니다. 그러나 우리는 이 단점보다 이점이 더 크다고 주장합니다.

이 접근 방식의 가장 큰 장점은 Python의 존재를 알지 못하는 JavaScript 함수를 메모리 누수 없이 사용할 수 있게 해 준다는 점입니다. 또 다른 장점은 PyProxy에 대한 파이널라이저 등록에 비용이 꽤 들기 때문에, 이 단계를 생략하면 Python에서 JavaScript로의 특정 호출에서 오버헤드를 상당히 줄일 수 있다는 점입니다.

호출 규약의 단점을 보여 주는 예

먼저 Python에서 JavaScript로의 호출 규약에 대해 흔히 제기되는 불만을 예시로 설명하겠습니다. 다음 예를 살펴보십시오:

from jstypes.code import run_js
set_x = run_js("(x) => { globalThis.x = x; }")
get_x = run_js("(x) => globalThis.x")

set_x({})
get_x()

이 코드는 잘못되었습니다. set_x를 호출하면 PyProxy가 생성되지만, 호출이 끝나면 이 PyProxy는 파괴됩니다. get_x()를 호출하면 다음 오류가 발생합니다:

This borrowed proxy was automatically destroyed at the end of a function call.

메모리를 올바르게 관리하도록 이를 고치려면 set_x를 다음 함수로 바꿀 수 있습니다:

(x) => {
    globalThis.x?.destroy?.();
    globalThis.x = x?.copy?.() ?? x;
}

또는 다음과 같이 create_proxy()를 사용하여 Python 쪽에서 메모리를 관리할 수 있습니다:

from jstypes.ffi import JSDoubleProxy
from jstypes.code import run_js

setXJs = run_js("(x) => { globalThis.x = x; }")
def set_x(x):
    orig_x = get_x()
    if isinstance(orig_x, JSDoubleProxy):
        orig_x.destroy()
    xpx = create_proxy(x)
    setXJs(xpx)

이 추가 보일러플레이트를 올바르게 작성하는 것은 그리 어렵지 않습니다 – C에서 속성을 할당할 때 필요한 것과 대략 비슷한 수준입니다. 그러나 이는 사용자에게 무시할 수 없는 복잡성 비용을 부과하므로, 이 방식이 다른 대안보다 나은 이유를 정당화해야 합니다.

이 호출 규약 덕분에 더 간단해지는 사용 사례

버퍼를 반환하는 Python 함수 render()와, 버퍼를 캔버스에 표시하는 JavaScript 함수 drawImageToCanvas(buffer)가 있다고 가정해 봅시다. 버퍼가 네 개의 색상 채널을 가진 1024×1024 비트맵이라면 4메가바이트 크기의 버퍼입니다. 다음 코드를 생각해 보십시오:

@create_proxy
def main_loop():
    update()
    buf = render()
    drawImageToCanvas(buffer)
    requestAnimationFrame(main_loop)

여기에서 설명하는 호출 규약을 사용하면 매 호출 후 버퍼가 정상적으로 해제되어 메모리 사용량이 일정하게 유지되며, 제 테스트에서는 57메가바이트로 유지되었습니다.

buffer를 해제하는 일을 JavaScript 파이널라이저에 맡기면, 제 테스트에서는 malloc이 WebAssembly 힙의 공간을 모두 소진하고 메모리를 추가로 요청할 때까지 JavaScript 파이널라이저가 실행되지 않았습니다. 그 결과 몇 분에 걸쳐 WebAssembly 힙이 허용 최대치인 4기가바이트까지 점차 커진 후 메모리 오류가 발생했습니다.

물론 협조적으로 구현된 drawImageToCanvas()라면 작업이 끝났을 때 buffer를 파괴할 수도 있지만, 호출 규약을 설계할 때 제 원칙은 메모리 관리를 Python 쪽에서 처리할 수 있어야 한다는 것이었습니다. 이를 위해서는 현재와 같은 접근 방식이 필요합니다.

새로운 최상위 패키지

jstypes라는 새로운 최상위 패키지를 도입합니다.

jstypes 패키지에는 jstypes.code와 jstypes.ffi라는 두 개의 모듈이 있습니다. jstypes.global_this 패키지는 JavaScript 전역 스코프인 globalThis입니다. jstypes.global_this 모듈에 어떤 값들이 존재하는지는 JavaScript 런타임에 따라, 그리고 Python 런타임이 메인 스레드와 워커 스레드 중 어디에서 실행되는지에 따라 달라집니다. 예를 들어 from jstypes.global_this import Buffer는 Node에서는 성공하지만 브라우저에서는 실패합니다.

jstypes.code는 run_js 함수를 노출합니다.

jstypes.ffi는 다음 함수들을 노출합니다:

create_proxy
Python에서 PyProxy를 생성합니다. Python에서 PyProxy의 수명을 제어하는 데 사용됩니다.
jsnull
JavaScript null 값과 상호 변환되는 특수 값입니다.
JSNull
jsnull의 타입입니다.
JSBigInt
JavaScript bigint와 상호 변환되는 int의 서브타입입니다.
to_js
Python 값을 JavaScript로 깊은 변환을 수행합니다.

또한 JSProxy와 그 서브타입도 포함합니다:

JSProxy
이는 type(run_js("({})"))입니다
JSArray
이는 type(run_js("[]"))입니다.
JSCallable
이는 type(run_js("() => {}"))입니다.
JSDoubleProxy
이는 type(create_proxy({}))입니다.
JSException
이는 type(run_js("new Error()"))입니다.
JSGenerator
이는 type(run_js("(function*(){})()"))입니다.
JSIterable
이는 type(run_js("({[Symbol.iterator](){}})"))입니다.
JSIterator
이는 type(run_js("({next(){}})"))입니다.
JSMap
이는 type(run_js("({get(){}})"))입니다.
JSMutableMap
이는 type(run_js("new Map()"))입니다.

명세

이 문서의 의사 코드

이 PEP의 의사 코드는 대체로 Python 또는 JavaScript로 작성되어 있습니다. 특별히 흥미롭다고 생각되는 경우를 제외하면 리소스 관리와 예외 처리는 대부분 생략합니다. 오류가 발생하면 암묵적으로 모든 리소스를 정리하고 오류를 전파합니다. 실제 코드의 상당 부분은 리소스 관리와 예외 처리로 이루어져 있습니다.

대부분의 코드는 작성된 그대로 동작하지만, 몇몇 부분에서는 Python에서 C API를 직접 호출하거나, 실제로 실행되지는 않지만 의도는 분명하다고 생각되는 코드를 작성합니다.

Python 코드에서 JavaScript 함수를 인라인으로 실행하려는 경우에는 다음과 같이 작성합니다:

jsfunc = run_js("(x, y) => doSomething")
jsfunc(x, y)

반대로 JavaScript에서 Python 코드를 인라인으로 실행하려는 경우에는 다음과 같이 작성합니다:

const pyfunc = makePythonFunction(`
    def pyfunc(x, y):
        # do something
`);
pyfunc(x, y)

성능을 고려하지 않는다면 이 코드는 대부분 실제로 사용할 수 있습니다. 일부 부분에서는 부트스트래핑 문제가 있을 수 있습니다.

첫 번째 작업은 Python 호출 가능 객체 run_js와 JavaScript 호출 가능 객체 makePythonFunction을 정의하는 것입니다. run_js는 JSProxy이고 makePythonFunction은 PyProxy입니다.

이를 이해하려면 다음을 설명해야 합니다.

  1. JavaScript에서 Python으로, 그리고 Python에서 JavaScript로 값을 변환하는 방법
  2. JavaScript에서 Python 함수를 호출하는 방법과 Python에서 JavaScript 함수를 호출하는 방법

PyObject*는 JavaScript에서 number로 직접 표현할 수 있으므로, JavaScript에서 PyObject*를 호출하는 과정을 설명할 수 있습니다. 반면에 JavaScript 객체는 Python에서 직접 표현할 수 없으므로, 해당 객체의 JSProxy를 만들어야 합니다. 먼저 JSProxy를 호출하는 과정을 설명하며, 이를 생성하는 과정은 JSProxies 절에서 설명합니다.

Python과 JavaScript 간의 값 변환

몇몇 원시 타입은 Python과 JavaScript 사이에서 암묵적으로 변환됩니다. 암묵적 변환은 왕복이 가능해야 합니다. 즉, Python에서 JavaScript로 변환했다가 다시 Python으로 변환하거나, JavaScript에서 Python으로 변환했다가 다시 JavaScript로 변환하면 처음과 같은 원시 값이 나와야 합니다. 유일한 예외는 2^53보다 작은 JavaScript BigInt가 왕복 후 Number가 된다는 점입니다. undefined는 None으로 변환하며, null을 변환하기 위해 거짓으로 평가되는 특수한 싱글턴 jstypes.ffi.jsnull을 도입합니다. 또한 JavaScript bigint와 상호 변환되는 jstypes.ffi.JSBigInt라는 int의 서브타입을 도입합니다.

암묵적 변환은 C 함수 _Py_python2js와 _Py_js2python()으로 수행합니다. JSVal 타입은 Python에서 표현할 수 없으므로 이 함수들은 Python 코드에서 직접 호출할 수 없습니다.

Python에서 JavaScript로의 암묵적 변환

JSVal _Py_python2js_track_proxies(PyObject* pyvalue, JSVal pyproxies, bool gc_register)는 Python에서 JavaScript로의 암묵적 변환을 담당합니다. 이 함수는 다음 단계를 수행합니다:

  1. pyvalue가 None이면 undefined를 반환합니다
  2. pyvalue가 jsnull이면 null을 반환합니다
  3. pyvalue가 True이면 true를 반환합니다
  4. pyvalue가 False이면 false를 반환합니다
  5. pyvalue가 str이면 문자열을 JavaScript로 변환하여 그 결과를 반환합니다.
  6. pyvalue가 JSBigInt의 인스턴스이면 BigInt로 변환합니다.
  7. pyvalue가 int이고 2^53보다 작으면 Number로 변환합니다. 그렇지 않으면 BigInt로 변환합니다.
  8. pyvalue가 float이면 Number로 변환합니다.
  9. pyvalue가 JSProxy이면 감싸고 있는 JavaScript 값으로 변환합니다.
  10. result를 createPyProxy(pyvalue, {gcRegister: gc_register})로 둡니다. pyproxies가 배열이면 result를 pyproxies에 추가합니다.

JSVal _Py_python2js(PyObject* pyvalue)를 _Py_python2js_track_proxies(pyvalue, Js_undefined, true)로 정의합니다.

JavaScript에서 Python으로의 암시적 변환

PyObject* _Py_js2python(JSVal jsvalue)는 JavaScript에서 Python으로의 암시적 변환을 담당합니다.

먼저 다음 단계를 수행하는 헬퍼 함수 PyObject* _Py_js2python_immutable(JSVal jsvalue)를 정의합니다.

  1. jsvalue가 undefined이면 None을 반환합니다.
  2. jsvalue가 null이면 jsnull을 반환합니다.
  3. jsvalue가 true이면 True를 반환합니다.
  4. jsvalue가 false이면 False를 반환합니다.
  5. jsvalue가 string이면 문자열을 Python으로 변환하여 그 결과를 반환합니다.
  6. jsvalue가 Number이고 Number.isSafeInteger(jsvalue)가 true를 반환하면 jsvalue를 int로 변환합니다. 그렇지 않으면 float로 변환합니다.
  7. jsvalue가 BigInt이면 JSBigInt로 변환합니다.
  8. jsvalue가 파괴되지 않은 PyProxy이면 감싸고 있는 Python 값으로 변환합니다.
  9. jsvalue가 파괴된 PyProxy이면 그 사실을 알리는 오류를 발생시킵니다.
  10. NoValue를 반환합니다.

_Py_js2python(JSVal jsvalue)는 다음 단계를 수행합니다.

  1. result를 _Py_js2python_immutable(jsvalue)로 둡니다. result가 NoValue가 아니면 result를 반환합니다.
  2. create_jsproxy(jsvalue)를 반환합니다.

오류 처리

JavaScript와 C 사이의 경계에서는 오류를 변환해야 합니다.

C에서 JavaScript 코드 실행하기

C에서 JavaScript 코드를 실행할 때는 항상 try/catch 블록으로 감쌉니다. 오류가 포착되면 _Py_js2python(jserror)를 사용하여 이를 Python 예외로 변환하고, Python 오류 플래그를 이 Python 예외로 설정한 다음, 오류를 알리는 적절한 오류 값을 반환합니다. 이렇게 하면 C에서 호출할 수 있으면서 C API에 대한 CPython의 일반적인 규약을 따르는 JavaScript 함수를 편리하게 만들 수 있습니다.

JavaScript에서 C 코드 실행하기

JavaScript에서 C를 호출할 때는 항상 다음과 같은 상용구 코드로 호출을 감쌉니다:

try {
    result = some_c_function();
} catch (e) {
    // If an error was thrown here, the C runtime state is corrupted.
    // Signal a fatal error and tear down the interpreter.
    fatal_error(e);
}
// Depending on the API, we check for -1, 0, _PyErr_Occurred(), etc to
// decide if an error occurred.
if (result === -1) {
    // This function takes the error flag and converts it to a JavaScript
    // exception. It leaves the error flag cleared.
    throw __Py_pythonexc2js();
}

호출 규약

JavaScript에서 Python 함수 호출하기

JavaScript에서 PyObject*를 호출하려면 다음 코드를 사용합니다:

function callPyObjectKwargs(pyfuncptr, jsargs, kwargs) {
    const num_pos_args = jsargs.length;
    const kwargs_names = Object.keys(kwargs);
    const kwargs_values = Object.values(kwargs);
    const num_kwargs = kwargs_names.length;
    jsargs.push(...kwargs_values);
    // apply the usual error handling logic for calling from JavaScript into C.
    return _PyProxy_apply(pyfuncptr, jsargs, num_pos_args, kwargs_names, num_kwargs);
}

_PyProxy_apply(PyObject* callable, JSVal jsargs, Py_ssize_t num_pos_args, JSVal kwargs_names, Py_ssize_t num_kwargs)

  1. total_args를 num_pos_args + numkwargs로 둡니다.
  2. 길이가 total_args인 C 배열 pyargs를 생성합니다.
  3. 0부터 total_args - 1까지의 각 i에 대해 다음을 수행합니다:
    1. JavaScript 코드 jsargs[i]를 실행하고 그 결과를 jsitem에 저장합니다.
    2. pyargs[i]를 _Py_js2python(jsitem)으로 설정합니다.
  4. pykwnames를 길이가 numkwargs인 새 튜플로 둡니다.
  5. 0부터 numkwargs - 1까지의 각 i에 대해 다음을 수행합니다:
    1. JavaScript 코드 jskwnames[i]를 실행하고 그 결과를 jskey에 저장합니다.
    2. pykwnames의 i번째 항목을 _Py_js2python(jsitem)으로 설정합니다.
  6. pyresult를 PyObject_Vectorcall(callable, pyargs, num_pos_args, pykwnames)로 둡니다.
  7. _Py_python2js(pyresult)를 반환합니다.

Python에서 JavaScript 함수 호출하기

``JSMethod_ConvertArgs(posargs, kwargs, pyproxies)``

먼저 Python 인자를 JavaScript 인자 배열로 변환하는 함수 JSMethod_ConvertArgs를 정의합니다. 이 단계에서 생성된 PyProxy는 종료 처리 레지스트리에 의해 추적되지 않으며, 나중에 파기하거나 추적할 수 있도록 JavaScript 리스트 pyproxies에 추가됩니다. 이 함수는 다음 단계를 수행합니다:

  1. jsargs를 새로운 빈 JavaScript 리스트로 둡니다.
  2. 각 위치 인자에 대해 다음을 수행합니다:
    1. JSVal jsarg = _Py_python2js_track_proxies(pyarg, proxies, /*gc_register:*/false);로 설정합니다.
    2. _PyJsvArray_Push(jsargs, arg);를 호출합니다.
  3. 키워드 인자가 있으면 다음을 수행합니다:
    1. jskwargs를 새로운 빈 JavaScript 객체로 둡니다.
    2. 각 키워드 인자 pykey, pyvalue에 대해 다음을 수행합니다:
      1. JSVal jskey = _Py_python2js(pykey)로 설정합니다.
      2. JSVal jsvalue = _Py_python2js_track_proxies(pyvalue, proxies, /*gc_register:*/false)로 설정합니다.
      3. jskwargs의 jskey 속성을 jsvalue로 설정합니다.
    3. _PyJsvArray_Push(jsargs, jskwargs);를 호출합니다.
  4. jsargs를 반환합니다.

``JSMethod_Vectorcall(jsproxy, posargs, kwargs)``

함수의 각 JSProxy에는 기반이 되는 JavaScript 함수와 기반이 되는 this 값이 있습니다.

  1. jsfunc를 jsproxy에 연결된 JavaScript 함수로 둡니다.
  2. jsthis를 jsproxy에 연결된 this 값으로 둡니다.
  3. pyproxies를 새로운 빈 JavaScript 리스트로 둡니다.
  4. JSMethod_ConvertArgs(posargs, kwargs, pyproxies)를 실행하고 그 결과를 jsargs에 저장합니다.
  5. JavaScript 코드 Function.prototype.apply.apply(jsfunc, [ jsthis, jsargs ])를 실행하고 그 결과를 jsresult에 저장합니다. (C에서 JavaScript를 호출할 때의 일반적인 오류 처리를 적용합니다.)
  6. jsresult가 PyProxy이면 JavaScript 코드 pyproxies.push(jsresult)를 실행합니다.
  7. destroy_args를 true로 설정합니다.
  8. jsresult가 Generator이면 destroy_args를 false로 설정하고 jsresult를 wrap_generator(jsresult, pyproxies)로 설정합니다.
  9. _Py_js2python(jsresult)를 실행하고 그 결과를 pyresult에 저장합니다.
  10. destroy_args가 true이면 pyproxies의 모든 프록시를 파괴합니다.
  11. destroy_args가 false이면 pyproxies의 모든 프록시를 GC에 등록합니다.
  12. pyresult를 반환합니다.

wrap_generator(jsresult, pyproxies)는 JavaScript 제너레이터를 새 제너레이터로 감싸는 JavaScript 함수로, 이 새 제너레이터는 소진되면 pyproxies의 모든 프록시를 파괴합니다.

run_js

Python 객체 jstypes.code.run_js는 다음과 같이 정의됩니다:

  1. JavaScript 코드 eval을 실행하고 그 결과를 jseval에 저장합니다.
  2. _Py_js2python(jseval)을 실행하고 그 결과를 run_js에 저장합니다.

makePythonFunction

run_js와 달리, JavaScript 객체 makePythonFunction은 오로지 이 의사 코드를 위한 것이며 API의 일부로 포함되지 않습니다. makePythonFunction을 다음과 같이 정의합니다:

def make_python_function(code):
    mod = ast.parse(code)
    if isinstance(mod.body[0], ast.FunctionDef):
        d = {}
        exec(code, d)
        return d[mod.body[0].name]
    return eval(code)
  1. 위의 함수를 make_python_function이라고 합시다.
  2. _Py_python2js(make_python_function)을 실행하고 그 결과를 makePythonFunction에 저장합니다.

JSProxy

JavaScript 객체가 지원할 수 있는 14가지 추상 프로토콜을 정의합니다. 이들은 각각 JSProxy 타입 플래그에 대응합니다. 또한 IS_PY_JSON_DICT와 IS_PY_JSON_SEQUENCE라는 두 개의 추가 플래그가 있는데, 이들은 JSProxy.as_py_json() 메서드가 설정하며 기반 JavaScript 객체의 속성을 반영하지 않습니다.

HAS_GET
JavaScript 객체에 get() 메서드가 있는지 여부를 나타냅니다. 존재하면 JSProxy의 __getitem__을 구현하는 데 사용됩니다.
HAS_HAS
JavaScript 객체에 has() 메서드가 있는지 여부를 나타냅니다. 존재하면 JSProxy의 __contains__를 구현하는 데 사용됩니다.
HAS_INCLUDES
JavaScript 객체에 includes() 메서드가 있는지 여부를 나타냅니다. 존재하면 JSProxy의 __contains__를 구현하는 데 사용됩니다. 둘 다 존재하는 경우 includes()보다 has()를 우선하여 사용합니다.
HAS_LENGTH
JavaScript 객체에 length 또는 size 속성이 있는지 여부를 나타냅니다. JSProxy의 __len__을 구현하는 데 사용됩니다.
HAS_SET
JavaScript 객체에 set() 메서드가 있는지 여부를 나타냅니다. 존재하면 JSProxy의 __setitem__을 구현하는 데 사용됩니다.
HAS_DISPOSE
JavaScript 객체에 [Symbol.dispose]() 메서드가 있는지 여부를 나타냅니다. 존재하면 __enter__와 __exit__를 구현하는 데 사용됩니다.
IS_ARRAY
JavaScript 객체에 Array.isArray()를 적용했을 때 true를 반환하는지 여부를 나타냅니다. 이 플래그가 있으면 JSProxy는 collections.abc.MutableSequence의 인스턴스가 됩니다.
IS_ARRAY_LIKE
Array.isArray()가 false를 반환하고, 객체에 length 속성이 있으며 IS_ITERABLE인 경우 이 플래그를 설정합니다. 이 플래그가 있으면 JSProxy는 collections.abc.Sequence의 인스턴스가 됩니다. NodeList 등 webidl에 정의된 많은 인터페이스가 이에 해당합니다.
IS_CALLABLE
JavaScript 객체의 typeof가 "function"인지 여부를 나타냅니다. 이 플래그가 있으면 JSProxy의 __call__을 구현하는 데 사용됩니다.
IS_ERROR
JavaScript 객체가 Error인지 여부를 나타냅니다. 그렇다면 JSProxy는 Exception의 서브클래스가 되므로 예외로 발생시킬 수 있습니다.
IS_GENERATOR
JavaScript 객체가 제너레이터인지 여부를 나타냅니다. 그렇다면 JSProxy는 collections.abc.Generator의 인스턴스가 됩니다.
IS_ITERABLE
JavaScript 객체에 [Symbol.iterator] 메서드가 있거나 IS_PY_JSON_DICT 플래그가 설정되어 있는지 여부를 나타냅니다. 그렇다면 이를 사용하여 JSProxy의 __iter__를 구현합니다.
IS_ITERATOR
JavaScript 객체에 next() 메서드가 있고 [Symbol.asyncIterator] 메서드가 없는지 여부를 나타냅니다. 그렇다면 이를 사용하여 JSProxy의 __next__를 구현합니다. ([Symbol.asyncIterator] 메서드가 있으면 __anext__를 구현하는 데 next() 메서드를 사용해야 한다고 가정합니다.)
IS_PY_JSON_DICT
Array가 아닌 경우 as_py_json() 메서드가 JSProxy에 이 플래그를 설정합니다. 이 플래그가 설정되면 JSProxy의 __getitem__은 JavaScript 객체에 대한 속성 접근으로 바뀝니다. 또한 프록시를 순회하거나 인덱싱하여 얻은 반환값에도 상황에 맞게 IS_PY_JSON_DICT 또는 IS_PY_JSON_SEQUENCE가 설정됩니다.
IS_PY_JSON_SEQUENCE
Array인 경우 as_py_json() 메서드가 JSProxy에 이 플래그를 설정합니다. 이 플래그가 설정되면 JSProxy를 인덱싱하거나 순회할 때 그 결과에 대해 as_py_json()을 호출합니다.
IS_MAPPING
HAS_GET, HAS_LENGTH, IS_ITERABLE 플래그가 설정되어 있거나 IS_PY_JSON_DICT가 설정되어 있으면 이 플래그를 설정합니다. 이 경우 JSProxy는 collections.abc.Mapping의 인스턴스가 됩니다.
IS_MUTABLE_MAPPING
IS_MAPPING 및 HAS_SET 플래그가 설정되어 있거나 IS_PY_JSON_DICT가 설정되어 있으면 이 플래그를 설정합니다. 이 경우 JSProxy는 collections.abc.MutableMapping의 인스턴스가 됩니다.

JSProxy 생성하기

JavaScript 객체와 값 jsthis로부터 JSProxy를 생성하려면 다음 단계를 수행합니다:

  1. JavaScript 객체에 적합한 타입 플래그를 계산합니다
  2. 설정된 타입 플래그 집합에 적합한 믹스인을 갖춘 적절한 JSProxy 클래스를 가져오거나, 생성하여 캐시합니다
  3. JavaScript 객체에 대한 참조와 jsthis 값으로 클래스를 인스턴스화합니다.

jsthis 값은 함수를 호출할 때 this의 값을 결정하는 데 사용됩니다. jsobj가 호출 가능 객체가 아니면 아무런 효과가 없습니다.

다음은 create_jsproxy 및 create_jsproxy_with_flags 함수의 의사 코드입니다:

def create_jsproxy(jsobj, jsthis=Js_undefined):
    # For the definition of ``compute_type_flags``, see "Determining which flags to set".
    return create_jsproxy_with_flags(compute_type_flags(jsobj), jsobj, jsthis)

def create_jsproxy_with_flags(type_flags, jsobj, jsthis):
    cls = get_jsproxy_class(type_flags)
    return cls.__new__(jsobj, jsthis)

가장 중요한 로직은 클래스를 생성하는 부분이며, 대략 다음과 같이 동작합니다:

@functools.cache
def get_jsproxy_class(type_flags):
    flag_mixin_pairs = [
        (HAS_GET, JSProxyHasGetMixin),
        (HAS_HAS, JSProxyHasHasMixin),
        # ...
        (IS_PY_JSON_DICT, JSPyJsonDictMixin)
    ]
    bases = [mixin for flag, mixin in flag_mixin_pairs if flag & type_flags]
    bases.insert(0, JSProxy)
    if type_flags & IS_ERROR:
        # We want JSException to be pickleable so it needs a distinct name
        name = "jstypes.ffi.JSException"
        bases.append(Exception)
    else:
        name = "jstypes.ffi.JSProxy"
    ns = {"_js_type_flags": type_flags}
    # Note: The actual way that we build the class does not result in the
    # mixins appearing as entries on the mro.
    return JSProxyMeta.__new__(JSProxyMeta, name, tuple(bases), ns)

JSProxy 메타클래스

이 메타클래스는 서브클래스 검사를 재정의하여, 한 JSProxy 클래스가 다른 JSProxy 클래스 플래그의 상위 집합을 가지면 그 클래스를 서브클래스로 보고합니다.

class _JSProxyMetaClass(type):
    def __instancecheck__(cls, instance):
        return cls.__subclasscheck__(type(instance))

    def __subclasscheck__(cls, subcls):
        if type.__subclasscheck__(cls, subcls):
            return True
        if not hasattr(subclass, "_js_type_flags"):
            return False

        subcls_flags = subcls._js_type_flags
        # Check whether the flags on subcls are a subset of the flags on cls
        return cls._js_type_flags & subcls_flags == subcls_flags

JSProxy 베이스 클래스

JSProxy 베이스 클래스에서 가장 복잡한 부분은 __getattribute__, __setattr__, __delattr__의 구현입니다. __getattribute__의 경우, 먼저 object.__getattribute__()를 호출하여 Python 객체 자체에 속성이 정의되어 있는지 확인합니다. 그렇지 않으면 JavaScript 객체에서 속성을 조회합니다.

__setattr__ 및 __delattr__의 경우, “__loader__”, “__name__”, “__package__”, “__path__”, “__spec__” 키는 Python 객체 자체에 설정합니다. 그 밖의 모든 값은 하부의 JavaScript 객체에 설정하거나 그 객체에서 삭제합니다. 이는 JavaScript 객체를 수정하지 않고도 Python 모듈로 사용할 수 있게 하기 위함입니다.

특이한 특수 사례로, 객체가 Array이면 keys 메서드를 걸러 냅니다. 또한 dir()의 결과에서도 이 메서드를 제거합니다. 이는 JavaScript Array가 전달되었을 때 dict.update()가 올바르게 동작하도록 하기 위함입니다. 우리가 원하는 동작은 다음과 같습니다:

d = {}
d.update(run_js("[['a', 'b'], [1, 2]]"))
assert d == {"a" : "b", 1 : 2}
# The result if we didn't filter out Array.keys would be as follows:
assert d != {1 : ['a', 'b'], 2: [1, 2]}

가능한 대안은 dict.update()에 JavaScript 배열에 대한 특수 사례 처리를 추가하는 것입니다.

JavaScript 객체에는 Python 키워드와 이름이 같은 중요한 메서드가 있는 경우가 흔합니다(예: Array.from, Promise.then). Python에서는 유효한 식별자인 from_ 및 then_을 사용하여 이러한 메서드에 접근합니다. then_이라는 JavaScript 프로퍼티에 접근하려면 then__으로 접근하며, 그 이후도 마찬가지입니다. 따라서 속성 이름이 Python 키워드 뒤에 하나 이상의 밑줄이 붙은 형태이면, 끝에서 밑줄 하나를 제거합니다. 이를 위해 다음 헬퍼 함수를 사용합니다:

def normalize_python_keywords(attr):
    stripped = attr.strip("_")
    if not keyword.iskeyword(stripped):
        return attr
    if stripped != attr:
        return attr[:-1]
    return attr

__bool__을 구현하려면 다음 JavaScript 함수가 필요합니다. JavaScript에서는 빈 컨테이너가 참으로 평가되지만 Python에서는 거짓으로 평가되어야 하므로, 빈 컨테이너를 감지하여 false를 반환합니다.

function js_bool(val) {
    // if it's a falsey JS object, return false
    if (!val) {
        return false;
    }
    // We also want to return false on container types with size 0.
    if (val.size === 0) {
        // Return true for HTML elements even if they have a size of zero.
        if (val instanceof HTMLElement) {
            return true;
        }
        return false;
    }
    // A function with zero arguments has a length property equal to
    // zero. Make sure we return true for this.
    if (val.length === 0 && Array.isArray(val)) {
        return false;
    }
    // An empty buffer
    if (val.byteLength === 0) {
        return false;
    }
    return true;

}

다음 헬퍼 함수는 __dir__를 구현하는 데 사용됩니다. 이 함수는 프로토타입 체인을 따라가며 모든 키를 모읍니다. 이때 숫자로 시작하는 키(유효한 Python 식별자가 아님)는 걸러내고, normalize_python_keywords 변환은 역으로 적용합니다. 또한 Array.keys 메서드도 걸러냅니다.

function js_dir(jsobj) {
    let result = [];
    let orig = jsobj;
    do {
        let keys = Object.getOwnPropertyNames(jsobj);
        result.push(...keys);
    } while ((jsobj = Object.getPrototypeOf(jsobj)));
    // Filter out numbers
    result = result.filter((s) => {
        let c = s.charCodeAt(0);
        return c < 48 || c > 57;
    });

    // Filter out "keys" key from an array
    if (Array.isArray(orig)) {
        result = result.filter((s) => {
            return s !== "keys";
        });
    }

    // If the key is a keyword followed by 0 or more underscores,
    // add an extra underscore to reverse the transformation applied by
    // normalize_python_keywords().
    result = result.map((word) =>
        iskeyword(word.replace(/_*$/, "")) ? word + "_" : word,
    );

    return result;
};
class JSProxy:
    def __getattribute__(self, attr):
        try:
            return object.__getattribute__(self, attr)
        except AttributeError:
            pass
        if attr == "keys" and Array.isArray(self):
            raise AttributeError(attr)

        attr = normalize_python_keywords(attr)
        js_getattr = run_js(
            """
            (jsobj, attr) => jsobj[attr]
            """
        )
        js_hasattr = run_js(
            """
            (jsobj, attr) => attr in jsobj
            """
        )
        result = js_getattr(self, attr)
        if isjsfunction(result):
            result = result.__get__(self)
        if result is None and not js_hasattr(self, attr):
            raise AttributeError(attr)
        return result

    def __setattr__(self, attr, value):
        if attr in ["__loader__", "__name__", "__package__", "__path__", "__spec__"]:
            return object.__setattr__(self, attr, value)
        attr = normalize_python_keywords(attr)
        js_setattr = run_js(
            """
            (jsobj, attr) => {
                jsobj[attr] = value;
            }
            """
        )
        js_setattr(self, attr, value)

    def __delattr__(self, attr):
        if attr in ["__loader__", "__name__", "__package__", "__path__", "__spec__"]:
            return object.__delattr__(self, attr)
        attr = normalize_python_keywords(attr)
        js_delattr = run_js(
            """
            (jsobj, attr) => {
                delete jsobj[attr];
            }
            """
        )
        js_delattr(self, attr)

    def __dir__(self):
        return object.__dir__(self) + js_dir(self)

    def __eq__(self, other):
        if not isinstance(other, JSProxy):
            return False
        js_eq = run_js("(x, y) => x === y")
        return js_eq(self, other)

    def __ne__(self, other):
        if not isinstance(other, JSProxy):
            return True
        js_neq = run_js("(x, y) => x !== y")
        return js_neq(self, other)

    def __repr__(self):
        js_repr = run_js("x => x.toString()")
        return js_repr(self)

    def __bool__(self):
        return js_bool(self)

    @property
    def js_id(self):
        """
        This returns an integer with the property that jsproxy1 == jsproxy2
        if and only if jsproxy1.js_id == jsproxy2.js_id. There is no way to
        express the implementation in pseudocode.
        """
        raise NotImplementedError

    def as_py_json(self):
        """
        This is actually a mixin method. We leave it out if any of the flags
        IS_CALLABLE, IS_DOUBLE_PROXY, IS_ERROR, or IS_ITERATOR
        is set.
        """
        flags = self._js_type_flags
        if (flags & (IS_ARRAY | IS_ARRAY_LIKE)):
            flags |= IS_PY_JSON_SEQUENCE
        else:
            flags |= IS_PY_JSON_DICT
        return create_jsproxy_with_flags(flags, self, self.jsthis)

    def to_py(self, *, depth=-1, default_converter=None):
        """
        See section on deep conversions.
        """
        ...

    def object_entries(self):
        js_object_entries = run_js("x => Object.entries(x)")
        return js_object_entries(self)

    def object_keys(self):
        js_object_keys = run_js("x => Object.keys(x)")
        return js_object_keys(self)

    def object_values(self):
        js_object_values = run_js("x => Object.values(x)")
        return js_object_values(self)

    def to_weakref(self):
        js_weakref = run_js("x => new WeakRef(x)")
        return js_weakref(self)

value에 as_py_json() 메서드가 있으면 이를 호출하는 다음 함수가 필요합니다:

def maybe_as_py_json(value):
    if (
        isinstance(value, JSProxy)
        and hasattr(value, as_py_json)
    ):
        return value.as_py_json()
    return value

설정할 플래그 결정하기

getTypeTag 헬퍼 함수가 필요합니다:

function getTypeTag(x) {
    try {
        return Object.prototype.toString.call(x);
    } catch (e) {
        // Catch and ignore errors
        return "";
    }
}

다음 함수를 사용하여 설정할 플래그를 결정합니다:

function compute_type_flags(obj, is_py_json) {
    let type_flags = 0;

    const typeTag = getTypeTag(obj);
    const hasLength =
        isArray || (hasProperty(obj, "length") && typeof obj !== "function");

    SET_FLAG_IF_HAS_METHOD(HAS_GET, "get");
    SET_FLAG_IF_HAS_METHOD(HAS_SET, "set");
    SET_FLAG_IF_HAS_METHOD(HAS_HAS, "has");
    SET_FLAG_IF_HAS_METHOD(HAS_INCLUDES, "includes");
    SET_FLAG_IF(
        HAS_LENGTH,
        hasProperty(obj, "size") || hasLength
    );
    SET_FLAG_IF_HAS_METHOD(HAS_DISPOSE, Symbol.dispose);
    SET_FLAG_IF(IS_CALLABLE, typeof obj === "function");
    SET_FLAG_IF(IS_ARRAY, Array.isArray(obj));
    SET_FLAG_IF(
        IS_ARRAY_LIKE,
        !isArray && hasLength && (type_flags & IS_ITERABLE));
    SET_FLAG_IF(IS_DOUBLE_PROXY, isPyProxy(obj));
    SET_FLAG_IF(IS_GENERATOR, typeTag === "[object Generator]");
    SET_FLAG_IF_HAS_METHOD(IS_ITERABLE, Symbol.iterator);
    SET_FLAG_IF(
        IS_ERROR,
        hasProperty(obj, "name") &&
        hasProperty(obj, "message") &&
        (hasProperty(obj, "stack") || constructorName === "DOMException") &&
        !(type_flags & IS_CALLABLE)
    );

    if (is_py_json && type_flags & (IS_ARRAY | IS_ARRAY_LIKE)) {
        type_flags |= IS_PY_JSON_SEQUENCE;
    } else if (
        is_py_json &&
        !(type_flags & (IS_DOUBLE_PROXY | IS_ITERATOR | IS_CALLABLE | IS_ERROR))
    ) {
        type_flags |= IS_PY_JSON_DICT;
    }
    const mapping_flags = HAS_GET | HAS_LENGTH | IS_ITERABLE;
    const mutable_mapping_flags = mapping_flags | HAS_SET;
    SET_FLAG_IF(IS_MAPPING, type_flags & (mapping_flags === mapping_flags));
    SET_FLAG_IF(
        IS_MUTABLE_MAPPING,
        type_flags & (mutable_mapping_flags === mutable_mapping_flags),
    );

    SET_FLAG_IF(IS_MAPPING, type_flags & IS_PY_JSON_DICT);
    SET_FLAG_IF(IS_MUTABLE_MAPPING, type_flags & IS_PY_JSON_DICT);

    return type_flags;
}

HAS_GET 믹스인

JavaScript get() 메서드가 있으면 __getitem__을 다음과 같이 정의합니다. has() 메서드도 있으면, 이를 사용하여 undefined 반환값을 키 오류로 처리할지 None으로 처리할지 결정합니다. has() 메서드가 없으면 undefined는 None으로 처리됩니다.

function js_get(jsobj, item) {
    const result = jsobj.get(item);
    if (result !== undefined) {
        return result;
    }
    if (hasMethod(obj, "has") && !obj.has(key)) {
        throw new PythonKeyError(item);
    }
    return undefined;
}
class JSProxyHasGetMixin:
    def __getitem__(self, item):
        result = js_get(self, item)
        if self._js_type_flags & IS_PY_JSON_DICT:
            result = maybe_as_py_json(result)
        return result

HAS_SET 믹스인

set() 메서드가 있으면 delete() 메서드도 있다고 가정하고 __setitem__과 __delitem__을 다음과 같이 정의합니다:

class JSProxyHasSetMixin:
    def __setitem__(self, item, value):
        js_set = run_js(
            """
            (jsobj, item, value) => {
                jsobj.set(item, value);
            }
            """
        )
        js_set(self, item, value)

    def __delitem__(self, item, value):
        js_delete = run_js(
            """
            (jsobj, item) => {
                jsobj.delete(item);
            }
            """
        )
        js_delete(self, item)

HAS_HAS 믹스인

class JSProxyHasHasMixin:
    def __contains__(self, item):
        js_has = run_js(
            """
            (jsobj, item) => jsobj.has(item);
            """
        )
        return js_has(self, item)

HAS_INCLUDES 믹스인

class JSProxyHasIncludesMixin:
    def __contains__(self, item):
        js_includes = run_js(
            """
            (jsobj, item) => jsobj.includes(item);
            """
        )
        return js_includes(self, item)

HAS_LENGTH 믹스인

size 속성이 존재하고 숫자이면 이를 우선 사용하고, 그렇지 않으면 length를 반환합니다. 두 필드 중 어느 것을 조회할 때든 JavaScript 오류가 발생하면, 이를 JavaScriptException으로 Python에 전파되도록 합니다.

class JSProxyHasLengthMixin:
    def __len__(self, item):
        js_len = run_js(
            """
            (jsobj) => {
                const size = val.size;
                if (typeof size === "number") {
                    return size;
                }
                return val.length
            }
            """
        )
        result = js_len(self)
        if not isinstance(result, int):
            raise TypeError("object does not have a valid length")
        if result < 0:
            raise ValueError("length of object is negative")
        return result

HAS_DISPOSE 믹스인

이를 통해 JSProxy는 __enter__가 아무 작업도 하지 않고 __exit__가 [Symbol.dispose]() 메서드를 호출하는 컨텍스트 관리자가 됩니다.

class JSProxyContextManagerMixin:
    def __enter__(self):
        return self

    def __exit__(self, type, value, traceback):
        js_symbol_dispose = run_js(
            """
            (jsobj) => jsobj[Symbol.dispose]()
            """
        )
        js_symbol_dispose(self)

IS_ARRAY 믹스인

function js_array_slice(jsobj, length, start, stop, step) {
    let result;
    if (step === 1) {
        result = obj.slice(start, stop);
    } else {
        result = Array.from({ length }, (_, i) => obj[start + i * step]);
    }
    return result;
}

// we also use this for deletion by setting values to None
function js_array_slice_assign(obj, slicelength, start, stop, step, values) {
    if (step === 1) {
        obj.splice(start, slicelength, ...(values ?? []));
        return;
    }
    if (values !== undefined) {
        for (let i = 0; i < slicelength; i++) {
            obj.splice(start + i * step, 1, values[i]);
        }
    }
    for (let i = slicelength - 1; i >= 0; i --) {
        obj.splice(start + i * step, 1);
    }
}
class JSArrayMixin(MutableSequence, JSProxyHasLengthMixin):
    def __getitem__(self, index):
        if not isinstance(index, (int, slice)):
            raise TypeError("Expected index to be an int or a slice")
        length = len(self)
        js_array_get = run_js(
            """
            (jsobj, index) => jsobj[index]
            """
        )
        if isinstance(index, int):
            if index >= length:
                raise IndexError(index)
            if index < -length:
                raise IndexError(index)
            if index < 0:
                index += length
            result = js_array_get(self, index)
            if self._js_type_flags & IS_PY_JSON_SEQUENCE:
                result = maybe_as_py_json(result)
            return result
        start = index.start
        stop = index.stop
        step = index.step
        slicelength = PySlice_AdjustIndices(length, &start, &stop, &step)
        if (slicelength <= 0) {
            return _PyJsvArray_New();
        }
        result = js_array_slice(self, slicelength, start, stop, step)
        if self._js_type_flags & IS_PY_JSON_SEQUENCE:
            result = result.as_py_json()
        return result


    def __setitem__(self, index, value):
        if not isinstance(index, (int, slice)):
            raise TypeError("Expected index to be an int or a slice")
        length = len(self)
        js_array_set = run_js(
            """
            (jsobj, index, value) => { jsobj[index] = value; }
            """
        )
        if isinstance(index, int):
            if index >= length:
                raise IndexError(index)
            if index < -length:
                raise IndexError(index)
            if index < 0:
                index += length
            result = js_array_set(self, index, value)
            return
        if not isinstance(value, Iterable):
            raise TypeError("must assign iterable to extended slice")
        seq = list(value)
        start = index.start
        stop = index.stop
        step = index.step
        slicelength = PySlice_AdjustIndices(length, &start, &stop, &step)
        if step != 1 and len(seq) != slicelength:
            raise TypeError(
                f"attempted to assign sequence of length {len(seq)} to"
                f"extended slice of length {slicelength}"
            )
        if step != 1 and slicelength == 0:
            return
        js_array_slice_assign(self, slicelength, start, stop, step, seq)

    def __delitem__(self, index):
        if not isinstance(index, (int, slice)):
            raise TypeError("Expected index to be an int or a slice")
        length = len(self)
        js_array_delete = run_js(
            """
            (jsobj, index) => { jsobj.splice(index, 1); }
            """
        )
        if isinstance(index, int):
            if index >= length:
                raise IndexError(index)
            if index < -length:
                raise IndexError(index)
            if index < 0:
                index += length
            result = js_array_delete(self, index)
            return
        start = index.start
        stop = index.stop
        step = index.step
        slicelength = PySlice_AdjustIndices(length, &start, &stop, &step)
        if step != 1 and slicelength == 0:
            return
        js_array_slice_assign(self, slicelength, start, stop, step, None)

    def insert(self, pos, value):
        if not isinstance(pos, int):
            raise TypeError("Expected an integer")
        js_insert = run_js(
            """
            (jsarr, pos, value) => { jsarr.splice(pos, value); }
            """
        )
        js_insert(self, pos, value)

IS_ARRAY_LIKE 믹스인

class JSArrayLikeMixin(MutableSequence, JSProxyHasLengthMixin):
    def __getitem__(self, index):
        if not isinstance(index, int):
            raise TypeError("Expected index to be an int")
        JSArrayMixin.__getitem__(self, index)

    def __setitem__(self, index, value):
        if not isinstance(index, int):
            raise TypeError("Expected index to be an int")
        JSArrayMixin.__setitem__(self, index, value)

    def __delitem__(self, index):
        if not isinstance(index, int):
            raise TypeError("Expected index to be an int")
        JSArrayMixin.__delitem__(self, index, value)

IS_CALLABLE 믹스인

JSCallable을 호출하는 더 정확한 C 코드는 이미 앞에서 제시했습니다. 특히 그곳에 제시된 JSMethod_ConvertArgs()의 정의를 참고하십시오.

class JSCallableMixin:
    def __get__(self, obj):
        """Return a new jsproxy bound to jsthis with the same JS object"""
        return create_jsproxy(self, jsthis=obj)

    def __call__(self, *args, **kwargs):
        """See the description of JSMethod_Vectorcall"""

    def new(self, *args, **kwargs):
        pyproxies = []
        jsargs = JSMethod_ConvertArgs(args, kwargs, pyproxies)

        do_construct = run_js(
            """
            (jsfunc, jsargs) =>
                Reflect.construct(jsfunc, jsargs)
            """
        )
        result = do_construct(self, jsargs)
        msg = (
            "This borrowed proxy was automatically destroyed "
            "at the end of a function call."
        )
        for px in pyproxies:
            px.destroy(msg)
        return result

IS_ERROR 믹스인

이 경우 Exception과 JSProxy를 모두 상속합니다. 또한 결과 클래스가 피클 가능하도록 합니다.

IS_ITERABLE 믹스인

이터러블에 IS_PY_JSON_DICT 플래그가 설정되어 있으면 객체 키를 순회합니다. 그렇지 않으면 obj[Symbol.iterator]()를 호출합니다. IS_PY_JSON_SEQUENCE 또는 IS_PY_JSON_DICT 중 하나가 설정되어 있으면, 순회 결과에 maybe_as_py_json을 호출합니다.

def wrap_with_maybe_as_py_json(it):
    try:
        while val := it.next()
            yield maybe_as_py_json(val)
    except StopIteration(result):
        return maybe_as_py_json(result)


class JSIterableMixin:
    def __iter__(self):
        pyjson = self._js_type_flags & (IS_PY_JSON_SEQUENCE | IS_PY_JSON_DICT)
        pyjson_dict = self._js_type_flags & IS_PY_JSON_DICT
        js_get_iter = run_js(
            """
            (obj) => obj[Symbol.iterator]()
            """
        )

        if pyjson_dict:
            result = iter(self.object_keys())
        else:
            result = js_get_iter(self)

        if pyjson:
            result = wrap_with_maybe_as_py_json(result)
        return result

IS_ITERATOR 믹스인

JavaScript next 메서드는 done 필드와 value 필드를 가진 IteratorResult를 반환합니다. done이 true이면, Python 이터레이터 프로토콜로 변환하기 위해 StopIteration 예외를 발생시켜야 합니다.

class JSIteratorMixin:
    def __iter__(self):
        return self

    def send(self, arg):
        js_next = run_js(
            """
            (obj, arg) => obj.next(arg)
            """
        )
        it_result = js_next(self, arg)
        value = it_result.value
        if it_result.done:
            raise StopIteration(value)
        return value

    def __next__(self):
        return self.send(None)

IS_GENERATOR 믹스인

Python 제너레이터에는 return() 메서드 대신 인자를 받지 않는 close() 메서드가 있습니다. 또한 gen.throw(GeneratorExit)를 jsgen.return_()으로 변환해야 합니다. 특정 값을 반환해야 한다면 jsgen.return_(val)을 직접 호출할 수 있습니다.

class JSGeneratorMixin(JSIteratorMixin):
    def throw(self, exc):
        if isinstance(exc, GeneratorExit):
            js_throw = run_js(
                """
                (obj, exc) => obj.return()
                """
            )
        else:
            js_throw = run_js(
                """
                (obj, exc) => obj.throw(exc)
                """
            )
        it_result = js_throw(self, exc)
        # if the error wasn't caught it will get raised back out.
        # now handle the case where the error got caught.
        value = it_result.value
        if self._js_type_flags & IS_PY_JSON_SEQUENCE:
            value = maybe_as_py_json(value)
        if it_result.done:
            raise StopIteration(value)
        return value

    def close(self):
        self.throw(GeneratorExit)

IS_MAPPING 믹스인

IS_MAPPING 플래그가 설정되어 있으면 모든 Mapping 메서드를 구현합니다. 추상 Mapping 메서드가 정의될 만큼 다른 플래그가 충분히 설정되어 있을 때에만 이 플래그를 설정합니다. 모든 믹스인 메서드에는 기본 구현을 사용합니다.

IS_MUTABLE_MAPPING 믹스인

IS_MUTABLE_MAPPING 플래그가 설정되어 있으면 모든 MutableMapping 메서드를 구현합니다. 추상 MutableMapping 메서드가 정의될 만큼 다른 플래그가 충분히 설정되어 있을 때에만 이 플래그를 설정합니다. 모든 믹스인 메서드에는 기본 구현을 사용합니다.

IS_PY_JSON_SEQUENCE 믹스인

이 플래그는 언제나 IS_ARRAY와 함께만 나타납니다. 이 플래그는 결과에 maybe_as_py_json()을 적용하도록 JSArray.__getitem__의 동작을 변경합니다.

IS_PY_JSON_DICT 믹스인

class JSPyJsonDictMixin(MutableMapping):
    def __getitem__(self, key):
        if not isinstance(key, str):
            raise KeyError(key)
        js_get = run_js(
            """
            (jsobj, key) => jsobj[key]
            """
        )
        result = js_get(self, key)
        if result is None and not key in self:
            raise KeyError(key)
        return maybe_as_py_json(result)

    def __setitem__(self, key, value):
        if not isinstance(key, str):
            raise TypeError("only keys of type string are supported")
        js_set = run_js(
            """
            (jsobj, key, value) => {
                jsobj[key] = value;
            }
            """
        )
        js_set(self, key, value)

    def __delitem__(self, key):
        if not isinstance(key, str):
            raise TypeError("only keys of type string are supported")
        if not key in self:
            raise KeyError(key)
        js_delete = run_js(
            """
            (jsobj, key) => {
                delete jsobj[key];
            }
            """
        )
        js_delete(self, key)

    def __contains__(self, key):
        if not isinstance(key, str):
            return False
        js_contains = run_js(
            """
            (jsobj, key) => key in jsobj
            """
        )
        return js_contains(self, key)

    def __len__(self):
        return sum(1 for _ in self)

    def __iter__(self):
        # defined by IS_ITERABLE mixin, see implementation there.

IS_DOUBLE_PROXY 믹스인

이 경우 객체는 PyProxy의 JSProxy입니다. 내부 Python 객체를 반환하는 unwrap() 메서드를 추가로 제공합니다.

PyProxy

Python 객체가 지원할 수 있으며, 그 객체로부터 만드는 PyProxy의 타입에 영향을 주는 12개의 믹스인을 정의합니다.

HAS_GET
Python 객체에 __getitem__ 메서드가 있으면 이 플래그를 설정합니다. 해당 메서드가 있으면 이를 사용하여 PyProxy에 get() 메서드를 구현합니다.
HAS_SET
Python 객체에 __setitem__ 메서드가 있으면 이 플래그를 설정합니다. 해당 메서드가 있으면 이를 사용하여 PyProxy에 set() 메서드를 구현합니다.
HAS_CONTAINS
Python 객체에 __contains__ 메서드가 있으면 이 플래그를 설정합니다. 이 플래그가 있으면 이를 사용하여 PyProxy에 has() 메서드를 구현합니다.
HAS_LENGTH
Python 객체에 __len__ 메서드가 있으면 이 플래그를 설정합니다. 이 플래그가 있으면 이를 사용하여 PyProxy에 length getter를 구현합니다.
IS_CALLABLE
Python 객체에 __call__ 메서드가 있으면 이 플래그를 설정합니다. 이 플래그가 있으면 PyProxy를 호출 가능 객체로 만듭니다.
IS_DICT
Python 객체의 타입이 정확히 dict이면 이 플래그를 설정합니다. 이 플래그가 있으면 getattr(pyobj, "some_property")가 AttributeError를 발생시킬 경우 pyproxy.some_property 프로퍼티가 pyobj.__getitem__("some_property")로 대체되도록 합니다.
IS_GENERATOR
Python 객체가 collections.abc.Generator의 인스턴스이면 이 플래그를 설정합니다. 이 플래그가 있으면 PyProxy가 JavaScript 제너레이터의 메서드를 구현하도록 합니다.
IS_ITERABLE
Python 객체에 __iter__ 메서드가 있으면 이 플래그를 설정합니다. 이 플래그가 있으면 이를 사용하여 PyProxy에 [Symbol.iterator] 메서드를 구현합니다.
IS_ITERATOR
Python 객체에 __next__ 메서드가 있으면 이 플래그를 설정합니다. 이 플래그가 있으면 이를 사용하여 PyProxy에 next() 메서드를 구현합니다.
IS_SEQUENCE
Python 객체가 collections.abc.Sequence의 인스턴스이면 이 플래그를 설정합니다. 이 플래그가 있으면 이를 사용하여 PyProxy에 객체를 변경하지 않는 모든 Array.prototype 메서드를 구현합니다.
IS_MUTABLE_SEQUENCE
Python 객체가 collections.abc.MutableSequence의 인스턴스이면 이 플래그를 설정합니다. 이 플래그가 있으면 이를 사용하여 PyProxy에 모든 Array.prototype 메서드를 구현합니다.
IS_JS_JSON_DICT
딕셔너리에 asJsJson() 메서드를 사용하면 이 플래그를 설정합니다. 이 플래그가 설정되어 있으면 PyProxy의 프로퍼티 접근은 Python 객체의 속성은 보지 않고 _오직_ __getitem__에서 나온 값만 확인합니다. 또한 PyProxy를 인덱싱하거나 순회한 결과에 대해서도 asJsJson()을 호출합니다.
IS_JS_JSON_SEQUENCE
이 플래그는 Sequence에 asJsJson()이 사용될 때 설정합니다. 이 플래그가 설정되어 있으면 PyProxy를 인덱싱하거나 이터레이션한 결과에 asJsJson()을 호출합니다.

PyProxy는 JavaScript 클래스와 ES6 Proxy 핸들러 모음을 혼합하여 구성됩니다. 어떤 플래그가 있는지에 따라 적절한 믹스인 모음과 적절히 선택한 핸들러로 클래스를 구성합니다.

PyProxy가 생성되면 래핑된 Python 객체의 참조 횟수를 증가시킵니다. PyProxy가 파기되면 참조 횟수를 감소시키고 파기된 것으로 표시합니다. 그 결과, PyProxy로 무언가를 하려고 하면 그 객체에 _Py_js2python()을 호출하게 되고 오류가 발생합니다.

PyProxy 생성하기

타입 플래그 모음이 주어지면 다음 함수를 사용하여 PyProxy 클래스를 생성합니다:

let pyproxyClassMap = new Map();
function getPyProxyClass(flags: number) {
    let result = pyproxyClassMap.get(flags);
    if (result) {
        return result;
    }
    let descriptors: any = {};
    const FLAG_MIXIN_PAIRS: [number, any][] = [
        [HAS_CONTAINS, PyContainsMixin],
        // ... other flag mixin pairs
        [IS_MUTABLE_SEQUENCE, PyMutableSequenceMixin],
    ];
    for (let [feature_flag, methods] of FLAG_MIXIN_PAIRS) {
        if (flags & feature_flag) {
            Object.assign(
                descriptors,
                Object.getOwnPropertyDescriptors(methods.prototype),
            );
        }
    }
    // Use base constructor (just throws an error if construction is attempted).
    descriptors.constructor = Object.getOwnPropertyDescriptor(
        PyProxyProto,
        "constructor",
    );
    // $$flags static field
    Object.assign(
        descriptors,
        Object.getOwnPropertyDescriptors({ $$flags: flags }),
    );
    // We either inherit PyProxyFunction as the base class if we're callable or
    // from PyProxy if we're not.
    const superProto = flags & IS_CALLABLE ? PyProxyFunctionProto : PyProxyProto;
    const subProto = Object.create(superProto, descriptors);
    function NewPyProxyClass() {}
    NewPyProxyClass.prototype = subProto;
    pyproxyClassMap.set(flags, NewPyProxyClass);
    return NewPyProxyClass;
}

PyProxy를 생성하려면 적절한 핸들러도 가져올 수 있어야 합니다:

function getPyProxyHandlers(flags) {
    if (flags & IS_JS_JSON_DICT) {
        return PyProxyJsJsonDictHandlers;
    }
    if (flags & IS_DICT) {
        return PyProxyDictHandlers;
    }
    if (flags & IS_SEQUENCE) {
        return PyProxySequenceHandlers;
    }
    return PyProxyHandlers;
}

ES6 프록시의 대상 객체를 생성하기 위해 다음 함수를 사용합니다:

function createTarget(flags) {
    const pyproxyClass = getPyProxyClass(flags);
    if (!(flags & IS_CALLABLE)) {
        return Object.create(cls.prototype);
    }
    // In this case we are effectively subclassing Function in order to ensure
    // that the proxy is callable. With a Content Security Protocol that doesn't
    // allow unsafe-eval, we can't invoke the Function constructor directly. So
    // instead we create a function in the universally allowed way and then use
    // `setPrototypeOf`. The documentation for `setPrototypeOf` says to use
    // `Object.create` or `Reflect.construct` instead for performance reasons
    // but neither of those work here.
    const target = function () {};
    Object.setPrototypeOf(target, cls.prototype);
    // Remove undesirable properties added by Function constructor. Note: we
    // can't remove "arguments" or "caller" because they are not configurable
    // and not writable
    delete target.length;
    delete target.name;
    // prototype isn't configurable so we can't delete it but it is writable.
    target.prototype = undefined;
    return target;
}

createPyProxy는 다음 옵션을 받습니다:

플래그
이 옵션이 전달되면 객체의 기능을 다시 감지하는 대신 전달된 플래그를 사용합니다.
props
같은 수명을 가진 다른 PyProxy와 공유되지 않는 정보입니다.
shared
이 프록시와 같은 수명을 가진 모든 프록시 사이에 공유되는 데이터입니다.
gcRegister
이것을 JavaScript 가비지 컬렉터에 등록해야 합니까?
const pyproxyAttrsSymbol = Symbol("pyproxy.attrs");
function createPyProxy(
    pyObjectPtr: number,
    {
        flags,
        props,
        shared,
        gcRegister,
    }
) {
    if (gcRegister === undefined) {
        // register by default
        gcRegister = true;
    }

    // See the section "Determining which flags to set" for the definition of
    // get_pyproxy_flags
    const pythonGetFlags = makePythonFunction("get_pyproxy_flags");
    flags ??= pythonGetFlags(pyObjectPtr);
    const target = createTarget(flags);
    const handlers = getPyProxyHandlers(flags);
    const proxy = new Proxy(target, handlers);

    props = Object.assign(
        { isBound: false, captureThis: false, boundArgs: [], roundtrip: false },
        props,
    );

    // If shared was passed the new PyProxy will have a shared lifetime
    // with some other PyProxy.
    // This happens in asJsJson(), bind(), and captureThis().
    // It specifically does not happen in copy()
    if (!shared) {
        shared = {
            pyObjectPtr,
            destroyed_msg: undefined,
            gcRegistered: false,
        };
        _Py_IncRef(pyObjectPtr);
        if (gcRegister) {
            gcRegisterPyProxy(shared);
        }
    }
    target[pyproxyAttrsSymbol] = { shared, props };
    return proxy;
}

PyProxy 베이스 클래스

기본 핸들러는 다음과 같습니다:

function filteredHasKey(jsobj, jskey, filterProto) {
    let result = jskey in jsobj;
    if (jsobj instanceof Function) {
      // If we are a PyProxy of a callable we have to subclass function so that if
      // someone feature detects callables with `instanceof Function` it works
      // correctly. But the callable might have attributes `name` and `length` and
      // we don't want to shadow them with the values from `Function.prototype`.
      result &&= !(
        ["name", "length", "caller", "arguments"].includes(jskey) ||
        // we are required by JS law to return `true` for `"prototype" in pycallable`
        // but we are allowed to return the value of `getattr(pycallable, "prototype")`.
        // So we filter prototype out of the "get" trap but not out of the "has" trap
        (filterProto && jskey === "prototype")
      );
    }
    return result;
}

const PyProxyHandlers = {
    isExtensible() {
        return true;
    },
    has(jsobj, jskey) {
        // Must report "prototype" in proxy when we are callable.
        // (We can return the wrong value from "get" handler though.)
        if (filteredHasKey(jsobj, jskey, false)) {
            return true;
        }
        // hasattr will crash if given a Symbol.
        if (typeof jskey === "symbol") {
            return false;
        }
        if (jskey.startsWith("$")) {
            jskey = jskey.slice(1);
        }
        const pythonHasAttr = makePythonFunction("hasattr");
        return pythonHasAttr(jsobj, jskey);
    },
    get(jsobj, jskey) {
        // Preference order:
        // 1. stuff from JavaScript
        // 2. the result of Python getattr
        // pythonGetAttr will crash if given a Symbol.
        if (typeof jskey === "symbol" || filteredHasKey(jsobj, jskey, true)) {
            return Reflect.get(jsobj, jskey);
        }
        if (jskey.startsWith("$")) {
            jskey = jskey.slice(1);
        }
        // 2. The result of getattr
        const pythonGetAttr = makePythonFunction("getattr");
        return pythonGetAttr(jsobj, jskey);
    },
    set(jsobj, jskey, jsval) {
        let descr = Object.getOwnPropertyDescriptor(jsobj, jskey);
        if (descr && !descr.writable && !descr.set) {
        return false;
        }
        // pythonSetAttr will crash if given a Symbol.
        if (typeof jskey === "symbol" || filteredHasKey(jsobj, jskey, true)) {
            return Reflect.set(jsobj, jskey, jsval);
        }
        if (jskey.startsWith("$")) {
            jskey = jskey.slice(1);
        }
        const pythonSetAttr = makePythonFunction("setattr");
        pythonSetAttr(jsobj, jskey, jsval);
        return true;
    },
    deleteProperty(jsobj, jskey: string | symbol): boolean {
        let descr = Object.getOwnPropertyDescriptor(jsobj, jskey);
        if (descr && !descr.configurable) {
            // Must return "false" if "jskey" is a nonconfigurable own property.
            // Strict mode JS will throw an error here saying that the property cannot
            // be deleted.
            return false;
        }
        if (typeof jskey === "symbol" || filteredHasKey(jsobj, jskey, true)) {
            return Reflect.deleteProperty(jsobj, jskey);
        }
        if (jskey.startsWith("$")) {
            jskey = jskey.slice(1);
        }
        const pythonDelAttr = makePythonFunction("delattr");
        pythonDelAttr(jsobj, jskey);
        return true;
    },
    ownKeys(jsobj) {
        const pythonDir = makePythonFunction("dir");
        const result = pythonDir(jsobj).toJs();
        result.push(...Reflect.ownKeys(jsobj));
        return result;
    },
    apply(jsobj: PyProxy & Function, jsthis: any, jsargs: any): any {
        return jsobj.apply(jsthis, jsargs);
    },
};

그리고 베이스 클래스에는 다음 메서드가 있습니다:

class PyProxy {
    constructor() {
        throw new TypeError("PyProxy is not a constructor");
    }
    get [Symbol.toStringTag]() {
        return "PyProxy";
    }
    static [Symbol.hasInstance](obj: any): obj is PyProxy {
        return [PyProxy, PyProxyFunction].some((cls) =>
            Function.prototype[Symbol.hasInstance].call(cls, obj),
        );
    }
    get type() {
        const pythonType = makePythonFunction(`
            def python_type(obj):
                ty = type(obj)
                if ty.__module__  in ['builtins', 'main']:
                    return ty.__name__
                return ty.__module__ + "." + ty.__name__
        `);
        return pythonType(this);
    }
    toString() {
        const pythonStr = makePythonFunction("str");
        return pythonStr(this);
    }
    destroy(options) {
        const { shared } = proxy[pyproxyAttrsSymbol];
        if (!shared.pyObjectPtr) {
            // already destroyed
            return;
        }
        shared.pyObjectPtr = 0;
        shared.destroyed_msg = options.message ?? "Object has already been destroyed";
        _Py_DecRef(shared.pyObjectPtr);
    }
    [Symbol.dispose]() {
        this.destroy();
    }
    copy() {
        const { shared, props } = proxy[pyproxyAttrsSymbol];
        // Don't pass shared as an option since we want this new PyProxy to
        // have a distinct lifetime from the one we are copying.
        return createPyProxy(shared.pyObjectPtr, {
            flags: this.$$flags,
            props: attrs.props,
        });
    }
    toJs(options) {
        // See the definition of to_js in "Deep conversions".
    }
}

설정할 플래그 결정하기

이를 타입에만 의존하는 플래그를 계산하는 get_type_flags 구성 요소와, PyProxy가 beenJsJson 되었는지 여부에도 의존하는 구성 요소로 분리합니다

def get_type_flags(ty):
    from collections.abc import Generator, MutableSequence, Sequence

    flags = 0
    if hasattr(ty, "__len__"):
        flags |= HAS_LENGTH
    if hasattr(ty, "__getitem__"):
        flags |= HAS_GET
    if hasattr(ty, "__setitem__"):
        flags |= HAS_SET
    if hasattr(ty, "__contains__"):
        flags |= HAS_CONTAINS
    if ty is dict:
        # Currently we don't set this on subclasses.
        flags |= IS_DICT
    if hasattr(ty, "__call__"):
        flags |= IS_CALLABLE
    if hasattr(ty, "__iter__"):
        flags |= IS_ITERABLE
    if hasattr(ty, "__next__"):
        flags |= IS_ITERATOR
    if issubclass(ty, Generator):
        flags |= IS_GENERATOR
    if issubclass(ty, Sequence):
        flags |= IS_SEQUENCE
    if issubclass(ty, MutableSequence):
        flags |= IS_MUTABLE_SEQUENCE
    return flags

def get_pyproxy_flags(obj, is_js_json):
    flags = get_type_flags(type(obj))
    if not is_js_json:
        return flags
    if flags & IS_SEQUENCE:
        flags |= IS_JS_JSON_SEQUENCE
    elif flags & HAS_GET:
        flags |= IS_JS_JSON_DICT
    return flags

HAS_GET 믹스인

const pythonGetItem = makePythonFunction(`
    def getitem(obj, key):
        return obj[key]
`);
class PyProxyGetItemMixin {
    get(key) {
        let result = pythonGetItem(this, key);
        const isJsJson = !!(this.$$flags & (IS_JS_JSON_DICT | IS_JS_JSON_SEQUENCE));
        if (isJsJson && result.asJsJson) {
            result = result.asJsJson();
        }
        return result;
    }
    asJsJson() {
        const flags = this.$$flags | IS_JS_JSON_DICT;
        const { shared, props } = this[pyproxyAttrsSymbol];
        // Note: The PyProxy created here has the same lifetime as the PyProxy it is
        // created from. Destroying either destroys both.
        return createPyProxy(shared.ptr, { flags, shared, props });
    }
}

HAS_SET 믹스인

class PyProxySetItemMixin {
    set(key, value) {
        const pythonSetItem = makePythonFunction(`
            def setitem(obj, key, value):
                obj[key] = value
        `);
        pythonSetItem(this, key, value);
    }
    delete(key) {
        const pythonDelItem = makePythonFunction(`
            def delitem(obj, key):
                del obj[key]
        `);
        pythonDelItem(this, key);
    }
}

HAS_CONTAINS 믹스인

const pythonHasItem = makePythonFunction(`
    def hasitem(obj, key):
        return key in obj
`);
class PyContainsMixin {
    has(key) {
        return pythonHasItem(this, key);
    }
}

HAS_LENGTH 믹스인

const pythonLength = makePythonFunction("len");
class PyLengthMixin {
    get length() : number {
        return pythonLength(this);
    }
}

IS_CALLABLE 믹스인

이것이 PyProxy와 Function을 모두 상속하도록 사용자 정의 프로토타입과 클래스를 만들어야 합니다:

const PyProxyFunctionProto = Object.create(
    Function.prototype,
    Object.getOwnPropertyDescriptors(PyProxy.prototype),
);
function PyProxyFunction() {}
PyProxyFunction.prototype = PyProxyFunctionProto;

captureThis가 true이면 this를 첫 번째 인자로 삽입하고 바인딩된 인자를 추가하는 다음 헬퍼 함수를 사용합니다.

function _adjustArgs(pyproxy, jsthis, jsargs) {
    const { props } = this[pyproxyAttrsSymbol];
    const { captureThis, boundArgs, boundThis, isBound } = props;
    if (captureThis) {
        if (isBound) {
            return [boundThis].concat(boundArgs, jsargs);
        } else {
            return [jsthis].concat(jsargs);
        }
    }
    if (isBound) {
        return boundArgs.concat(jsargs);
    }
    return jsargs;
}

그런 다음 다음 메서드들을 구현합니다. apply(), call(), bind()는 Function.prototype의 메서드입니다. callKwargs()와 captureThis()는 PyProxy에만 있는 메서드입니다

export class PyCallableMixin {
    apply(thisArg, jsargs) {
        // Convert jsargs to an array using ordinary .apply in order to match the
        // behavior of .apply very accurately.
        jsargs = function (...args) {
            return args;
        }.apply(undefined, jsargs);
        jsargs = _adjustArgs(this, thisArg, jsargs);
        const pyObjectPtr = this[pyproxyAttrsSymbol].shared.pyObjectPtr;
        return callPyObjectKwargs(pyObjectPtr, jsargs, {});
    }
    call(thisArg, ...jsargs) {
        jsargs = _adjustArgs(this, thisArg, jsargs);
        const pyObjectPtr = this[pyproxyAttrsSymbol].shared.pyObjectPtr;
        return callPyObjectKwargs(pyObjectPtr, jsargs, {});
    }

    /**
     * Call the function with keyword arguments. The last argument must be an
     * object with the keyword arguments.
     */
    callKwargs(...jsargs) {
        jsargs = _adjustArgs(this, thisArg, jsargs);
        if (jsargs.length === 0) {
            throw new TypeError(
                "callKwargs requires at least one argument (the kwargs object)",
            );
        }
        let kwargs = jsargs.pop();
        if (
            kwargs.constructor !== undefined &&
            kwargs.constructor.name !== "Object"
        ) {
            throw new TypeError("kwargs argument is not an object");
        }
        const pyObjectPtr = this[pyproxyAttrsSymbol].shared.pyObjectPtr;
        return callPyObjectKwargs(pyObjectPtr, jsargs, kwargs);
    }
    /**
     * This is our implementation of Function.prototype.bind().
     */
    bind(thisArg, ...jsargs) {
        let { shared, props } = this[pyproxyAttrsSymbol];
        const { boundArgs: boundArgsOld, boundThis: boundThisOld, isBound } = props;
        let boundThis = thisArg;
        if (isBound) {
            boundThis = boundThisOld;
        }
        const boundArgs = boundArgsOld.concat(jsargs);
        props = Object.assign({}, props, {
            boundArgs,
            isBound: true,
            boundThis,
        });
        return createPyProxy(shared.ptr, {
            shared,
            flags: this.$$flags,
            props,
        });
    }
    /**
      * This method makes a new PyProxy where ``this`` is passed as the
      * first argument to the Python function. The new PyProxy has the
      * same lifetime as the original.
      */
    captureThis() {
        let { props, shared } = this[pyproxyAttrsSymbol];
        props = Object.assign({}, props, {
            captureThis: true,
        });
        return createPyProxy(shared.ptr, {
            shared,
            flags: this.$$flags,
            props,
        });
    }
}

IS_DICT 믹스인

IS_DICT 믹스인은 추가 메서드를 포함하지 않지만, 특별한 핸들러 집합을 사용합니다. 이 핸들러들은 일반 핸들러와 JS_JSON_DICT 핸들러를 혼합한 것입니다. 먼저 hasattr(d, property)인지 확인하고, 그렇다면 d.property를 반환합니다. 그렇지 않으면 d.get(property, None)을 반환합니다. 다른 메서드도 모두 비슷하게 동작합니다. 이 핸들러들의 정의는 IS_JS_JSON_DICT 플래그를 참고하십시오.

const PyProxyDictHandlers = {
    isExtensible(): boolean {
        return true;
    },
    has(jsobj: PyProxy, jskey: string | symbol): boolean {
        if (PyProxyHandlers.has(jsobj, jskey)) {
            return true;
        }
        return PyProxyJsJsonDictHandlers.has(jsobj, jskey);
    },
    get(jsobj: PyProxy, jskey: string | symbol): any {
        let result = PyProxyHandlers.get(jsobj, jskey);
        if (result !== undefined || PyProxyHandlers.has(jsobj, jskey)) {
            return result;
        }
        return PyProxyJsJsonDictHandlers.get(jsobj, jskey);
    },
    set(jsobj: PyProxy, jskey: string | symbol, jsval: any): boolean {
        if (PyProxyHandlers.has(jsobj, jskey)) {
            return PyProxyHandlers.set(jsobj, jskey, jsval);
        }
        return PyProxyJsJsonDictHandlers.set(jsobj, jskey, jsval);
    },
    deleteProperty(jsobj: PyProxy, jskey: string | symbol): boolean {
        if (PyProxyHandlers.has(jsobj, jskey)) {
            return PyProxyHandlers.deleteProperty(jsobj, jskey);
        }
        return PyProxyJsJsonDictHandlers.deleteProperty(jsobj, jskey);
    },
    getOwnPropertyDescriptor(jsobj: PyProxy, prop: any) {
        return (
            Reflect.getOwnPropertyDescriptor(jsobj, prop) ??
            PyProxyJsJsonDictHandlers.getOwnPropertyDescriptor(jsobj, prop)
        );
    },
    ownKeys(jsobj: PyProxy): (string | symbol)[] {
        const result = [
            ...PyProxyHandlers.ownKeys(jsobj),
            ...PyProxyJsJsonDictHandlers.ownKeys(jsobj)
        ];
        // deduplicate
        return Array.from(new Set(result));
    },
};

IS_ITERABLE 믹스인

const pythonNext = makePythonFunction("next");
const getStopIterationValue = makePythonFunction(`
    def get_stop_iteration_value():
        import sys
        err = sys.last_value
        return err.value
`);

function* iterHelper(iter, isJsJson) {
    try {
        while (true) {
            let item = pythonNext(iter);
            if (isJsJson && item.asJsJson) {
                item = item.asJsJson();
            }
            yield item;
        }
    } catch (e) {
        if (e.type === "StopIteration") {
            return getStopIterationValue();
        }
        throw e;
    }
}

const pythonIter = makePythonFunction("iter");
class PyIterableMixin {
    [Symbol.iterator]() {
        const isJsJson = !!(this.$$flags & (IS_JS_JSON_DICT | IS_JS_JSON_SEQUENCE));
        return iterHelper(pythonIter(this), isJsJson);
    }
}

IS_ITERATOR 믹스인

const pythonSend = makePythonFunction(`
    def python_send(it, val):
        return gen.send(val)
`);
class PyIteratorMixin {
    next(x) {
        try {
            const result = pythonSend(this, x);
            return { done: false, value: result };
        } catch (e) {
            if (e.type === "StopIteration") {
                const result = getStopIterationValue();
                return { done: true, value: result };
            }
            throw e;
        }
    }
}

IS_GENERATOR 믹스인

const pythonThrow = makePythonFunction(`
    def python_throw(gen, val):
        return gen.throw(val)
`);
const pythonClose = makePythonFunction(`
    def python_close(gen):
        return gen.close()
`);
class PyGeneratorMixin extends PyIteratorMixin {
    throw(exc) {
        try {
            const result = pythonThrow(this, exc);
            return { done: false, value: result };
        } catch (e) {
            if (e.type === "StopIteration") {
                const result = getStopIterationValue();
                return { done: true, value: result };
            }
            throw e;
        }
    }
    return(value) {
        pythonClose(this);
        return { done: true, value }
    }
}

IS_SEQUENCE 믹스인

시퀀스를 변경하지 않는 Array.prototype 메서드는 모두 PySequenceMixin에 정의합니다. 대부분은 Array 프로토타입 메서드가 변경 없이 그대로 동작합니다. 이 메서드들은 모두 다음 형태의 보일러플레이트 코드로 정의합니다:

[methodName](...args) {
    return Array.prototype[methodName].call(this, ...args)
}

여기에는 join, slice, indexOf, lastIndexOf, forEach, map, filter, some, every, reduce, reduceRight, at, concat, includes, entries, keys, values, find, findIndex가 포함됩니다. 이 보일러플레이트 메서드 외에 PySequenceMixin의 나머지 속성은 다음과 같습니다.

class PySequenceMixin {
    get [Symbol.isConcatSpreadable]() {
        return true;
    }
    toJSON() {
        return Array.from(this);
    }
    asJsJson() {
        const flags = this.$$flags | IS_JS_JSON_SEQUENCE;
        const { shared, props } = this[pyproxyAttrsSymbol];
        // Note: Because we pass shared down, the PyProxy created here has
        // the same lifetime as the PyProxy it is created from. Destroying
        // either destroys both.
        return createPyProxy(shared.ptr, { flags, shared, props });
    }
    // ... boilerplate methods
}

시퀀스에는 기본 프록시 핸들러 대신 다음 핸들러를 사용합니다. 다음은 하지 않습니다

const PyProxySequenceHandlers = {
    isExtensible() {
        return true;
    },
    has(jsobj, jskey) {
        if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) {
            // Note: if the number was negative it didn't match the pattern
            return Number(jskey) < jsobj.length;
        }
        return PyProxyHandlers.has(jsobj, jskey);
    },
    get(jsobj, jskey) {
        if (jskey === "length") {
            return jsobj.length;
        }
        if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) {
            try {
                return PyProxyGetItemMixin.prototype.get.call(jsobj, Number(jskey));
            } catch (e) {
                if (isPythonError(e) && e.type == "IndexError") {
                    return undefined;
                }
                throw e;
            }
        }
        return PyProxyHandlers.get(jsobj, jskey);
    },
    set(jsobj: PyProxy, jskey: any, jsval: any): boolean {
        if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) {
            try {
                PyProxySetItemMixin.prototype.set.call(jsobj, Number(jskey), jsval);
                return true;
            } catch (e) {
                if (isPythonError(e) && e.type == "IndexError") {
                return false;
                }
                throw e;
            }
        }
        return PyProxyHandlers.set(jsobj, jskey, jsval);
    },
    deleteProperty(jsobj: PyProxy, jskey: any): boolean {
        if (typeof jskey === "string" && /^[0-9]+$/.test(jskey)) {
            try {
                PyProxySetItemMixin.prototype.delete.call(jsobj, Number(jskey));
                return true;
            } catch (e) {
                if (isPythonError(e) && e.type == "IndexError") {
                    return false;
                }
                throw e;
            }
        }
        return PyProxyHandlers.deleteProperty(jsobj, jskey);
    },
    ownKeys(jsobj: PyProxy): (string | symbol)[] {
        const result = PyProxyHandlers.ownKeys(jsobj);
        result.push(
            ...Array.from({ length: jsobj.length }, (_, k) => k.toString()),
        );
        result.push("length");
        return result;
    },
};

IS_MUTABLE_SEQUENCE 믹스인

시퀀스를 변경하는 Array 메서드 몇 가지를 추가로 제공합니다.

class PyMutableSequenceMixin {
    reverse() {
        // Same as the Python reverse method except it returns this instead of undefined
        this.$reverse();
        return this;
    }
        push(...elts: any[]) {
        for (const elt of elts) {
            this.append(elt);
        }
        return this.length;
    }
    splice(start, deleteCount, ...items) {
        if (deleteCount === undefined) {
            // Max signed size
            deleteCount = (1 << 31) - 1;
        }
        let stop = start + deleteCount;
        if (stop > this.length) {
            stop = this.length;
        }
        const pythonSplice = makePythonFunction(`
            def splice(array, start, stop, items):
                from jstypes.ffi import to_js
                result = to_js(array[start:stop], depth=1)
                array[start:stop] = items
                return result
        `);
        return pythonSplice(this, start, stop, items);
    }
    pop() {
        const pythonPop = makePythonFunction(`
            def pop(array):
                return array.pop()
        `);
        return pythonPop(this);
    }
    shift() {
        const pythonShift = makePythonFunction(`
            def pop(array):
                return array.pop(0)
        `);
        return pythonShift(this);
    }
    unshift(...elts) {
        elts.forEach((elt, idx) => {
            this.insert(idx, elt);
        });
        return this.length;
    }
    // Boilerplate methods
    copyWithin(...args): any {
        Array.prototype.copyWithin.apply(this, args);
        return this;
    }
    fill(...args) {
        Array.prototype.fill.apply(this, args);
        return this;
    }
}

IS_JS_JSON_DICT 믹스인

IS_JS_JSON_DICT 플래그에만 있는 메서드는 없지만, 다음 프록시 핸들러를 사용합니다. 속성은 가급적 딕셔너리의 항목으로 조회하지만, 두 가지 예외가 있습니다:

  1. 심볼은 항상 PyProxy 자체에서 조회합니다.
  2. $$flags, copy(), constructor, destroy, toString 키도 PyProxy에서 조회합니다.

모든 Python 딕셔너리 메서드는 같은 이름의 키에 의해 가려집니다.

const PyProxyJsJsonDictHandlers = {
    isExtensible(): boolean {
        return true;
    },
    has(jsobj: PyProxy, jskey: string | symbol): boolean {
        if (PyContainsMixin.prototype.has.call(jsobj, jskey)) {
            return true;
        }
        // If it doesn't exist as a string key and it looks like a number,
        // try again with the number
        if (typeof jskey === "string" && /^-?[0-9]+$/.test(jskey)) {
            return PyContainsMixin.prototype.has.call(jsobj, Number(jskey));
        }
        return false;
    },
    get(jsobj, jskey): any {
        if (
            typeof jskey === "symbol" ||
            ["$$flags", "copy", "constructor", "destroy", "toString"].includes(jskey)
        ) {
            return Reflect.get(...arguments);
        }
        const result = PyProxyGetItemMixin.prototype.get.call(jsobj, jskey);
        if (
            result !== undefined ||
            PyContainsMixin.prototype.has.call(jsobj, jskey)
        ) {
            return result;
        }
        if (typeof jskey === "string" && /^-?[0-9]+$/.test(jskey)) {
            return PyProxyGetItemMixin.prototype.get.call(jsobj, Number(jskey));
        }
        return Reflect.get(...arguments);
    },
    set(jsobj, jskey, jsval): boolean {
        if (typeof jskey === "symbol") {
            return false;
        }
        if (
            !PyContainsMixin.prototype.has.call(jsobj, jskey) &&
            typeof jskey === "string" &&
            /^-?[0-9]+$/.test(jskey)
        ) {
            jskey = Number(jskey);
        }
        try {
            PyProxySetItemMixin.prototype.set.call(jsobj, jskey, jsval);
            return true;
        } catch (e) {
            if (isPythonError(e) && e.type === "KeyError") {
                return false;
            }
            throw e;
        }
    },
    deleteProperty(jsobj: PyProxy, jskey: string | symbol | number): boolean {
        if (typeof jskey === "symbol") {
            return false;
        }
        if (
            !PyContainsMixin.prototype.has.call(jsobj, jskey) &&
            typeof jskey === "string" &&
            /^-?[0-9]+$/.test(jskey)
        ) {
            jskey = Number(jskey);
        }
        try {
            PyProxySetItemMixin.prototype.delete.call(jsobj, jskey);
            return true;
        } catch (e) {
            if (isPythonError(e) && e.type === "KeyError") {
                return false;
            }
            throw e;
        }
    },
    getOwnPropertyDescriptor(jsobj: PyProxy, prop: any) {
        if (!PyProxyJsJsonDictHandlers.has(jsobj, prop)) {
            return undefined;
        }
        const value = PyProxyJsJsonDictHandlers.get(jsobj, prop);
        return {
            configurable: true,
            enumerable: true,
            value,
            writable: true,
        };
    },
    ownKeys(jsobj: PyProxy): (string | symbol)[] {
        const pythonDictOwnKeys = makePythonFunction(`
            def dict_own_keys(d):
                from jstypes.ffi import to_js
                result = set()
                for key in d:
                    if isinstance(key, str):
                        result.add(key)
                    elif isinstance(key, (int, float)):
                        result.add(str(key))
                return to_js(result)
        `);
        return pythonDictOwnKeys(jsobj);
    },
};

IS_JS_JSON_SEQUENCE 믹스인

이 믹스인은 프록시의 프로토타입이나 핸들러에 직접적인 영향을 주지 않습니다. 하지만 리스트를 인덱싱하거나 순회할 때는 그 결과에 asJsJson()을 적용합니다.

깊은 변환

JavaScript에서 Python으로 깊은 변환을 하기 위해 JSProxy.to_py()를, Python에서 JavaScript로 깊은 변환을 하기 위해 jstypes.ffi.to_js()를 정의합니다. 이 두 함수가 서로의 역함수가 되도록 의도한 것은 아니라는 점에 유의하십시오.

JavaScript에서 Python으로

JSProxy.to_py() 메서드는 다음과 같은 변환을 수행합니다:

  • Array ==> list
  • Map ==> dict
  • Set ==> set
  • Object ==> dict. 단, constructor가 Object 또는 undefined인 경우에만 해당합니다. 그 밖의 객체는 그대로 둡니다.

이 메서드는 다음과 같은 선택적 인자를 받습니다:

depth
정수이며, 변환할 최대 깊이를 지정합니다. 예를 들어 depth=1로 설정하면 정확히 한 단계만 변환할 수 있습니다.
default_converter
객체에 대해 알려진 변환 방법이 없을 때 호출되는 함수입니다.

기본 변환기는 세 개의 인자를 받습니다:

jsobj
변환할 객체입니다.
convert
재귀 호출을 할 수 있게 합니다.
cache_conversion
자기 참조 데이터를 변환할 수 있도록 객체의 변환 결과를 캐시합니다.

예를 들어 JavaScript Pair 클래스가 있고 이를 리스트로 변환하려는 경우, 다음과 같은 default_converter를 사용할 수 있습니다:

def pair_converter(jsobj, convert, cache_conversion):
    if jsobj.constructor.name != "Pair":
        return jsobj
    result = []
    cache_conversion(jsobj, result)
    result.append(convert(jsobj.first))
    result.append(convert(jsobj.second))
    return result

convert를 재귀 호출하기 전에 먼저 결과를 캐시해 두면, jsobj.first가 jsobj를 전이적으로 참조하는 경우에도 올바르게 변환할 수 있습니다.

to_py 메서드의 전체 의사 코드는 다음과 같습니다:

def to_py(jsobj, *, depth=-1, default_converter=None):
    cache = {}
    return ToPyConverter(depth, default_converter).convert(jsobj)

class ToPyConverter:
    def __init__(self, depth, default_converter):
        self.cache = {}
        self.depth = depth
        self.default_converter = default_converter

    def cache_conversion(self, jsobj, pyobj):
        self.cache[jsobj.js_id] = pyobj

    def convert(self, jsobj):
        if self.depth == 0 or not isinstance(jsobj, JSProxy):
            return jsobj
        if result := self.cache.get(jsobj.js_id):
            return result

        from jstypes.global_this import Array, Object
        type_tag = getTypeTag(jsobj)
        self.depth -= 1
        try:
            if Array.isArray(jsobj):
                return self.convert_list(jsobj)
            if type_tag == "[object Map]":
                return self.convert_map(jsobj, jsobj.entries())
            if type_tag == "[object Set]":
                return self.convert_set(jsobj)
            if type_tag == "[object Object]" and (jsobj.constructor in [None, Object]):
                return self.convert_map(jsobj, Object.entries(jsobj))
            if self.default_converter is not None:
                return self.default_converter(jsobj, self.convert, self.cache_conversion)
            return jsobj
        finally:
            self.depth += 1

    def convert_list(self, jsobj):
        result = []
        self.cache_conversion(jsobj, result)
        for item in jsobj:
            result.append(self.convert(item))
        return result

    def convert_map(self, jsobj, entries):
        result = {}
        self.cache_conversion(jsobj, result)
        for [key, val] in entries:
            result[key] = self.convert(val)
        return result

    def convert_set(self, jsobj):
        result = set()
        self.cache_conversion(jsobj, result)
        for key in jsobj:
            result.add(self.convert(key))
        return result

Python에서 JavaScript로

def to_js(
    obj,
    /,
    *,
    depth=-1,
    pyproxies=None,
    create_pyproxies=True,
    dict_converter=None,
    default_converter=None,
    eager_converter=None,
):
    converter = ToJsConverter(
        depth,
        pyproxies,
        create_pyproxies,
        dict_converter,
        default_converter,
        eager_converter,
    )
    result = converter.convert(obj)
    converter.postprocess()
    return result

class ToJsConverter:
    def __init__(
        self,
        depth,
        pyproxies,
        create_pyproxies,
        dict_converter,
        default_converter,
        eager_converter,
    ):
        self.depth = depth
        self.pyproxies = pyproxies
        self.create_pyproxies = create_pyproxies
        if dict_converter is None:
            dict_converter = Object.fromEntries
        self.dict_converter = dict_converter
        self.default_converter = default_converter
        self.eager_converter = eager_converter
        self.cache = {}
        self.post_process_list = []
        self.pairs_to_dict_map = {}

    def cache_conversion(self, pyobj, jsobj):
        self.cache[id(pyobj)] = jsobj

    def postprocess(self):
        # Replace any NoValue's that appear once we've certainly computed
        # their correct conversions
        for parent, key, pyobj_id in self.post_process_list:
            real_value = self.cache[pyobj_id]
            # If it was a dictionary, we need to lookup the actual result object
            real_parent = self.pairs_to_dict_map.get(parent.js_id, parent)
            real_parent[key] = real_value

    @contextmanager
    def decrement_depth(self):
        self.depth -= 1
        try:
            yield
        finally:
            self.depth += 1

    def convert(self, pyobj):
        if self.depth == 0 or isinstance(pyobj, JSProxy):
            return pyobj
        if result := self.cache.get(id(pyobj)):
            return result

        with self.decrement_depth():
            if self.eager_converter:
                return self.eager_converter(
                    pyobj, self.convert_no_eager_public, self.cache_conversion
                )
            return self.convert_no_eager(pyobj)

    def convert_no_eager_public(self, pyobj):
        with self.decrement_depth():
            return self.convert_no_eager(pyobj)

    def convert_no_eager(self, pyobj):
        if isinstance(pyobj, (tuple, list)):
            return self.convert_sequence(pyobj)
        if isinstance(pyobj, dict):
            return self.convert_dict(pyobj)
        if isinstance(pyobj, set):
            return self.convert_set(pyobj)
        if self.default_converter:
            return self.default_converter(
                pyobj, self.convert_no_eager_public, self.cache_conversion
            )
        if not self.create_pyproxies:
            raise ConversionError(
                f"No conversion available for {pyobj!r} and create_pyproxies=False passed"
            )
        result = create_proxy(pyobj)
        if self.pyproxies is not None:
            self.pyproxies.append(result)
        return result

    def convert_sequence(self, pyobj):
        from jstypes.global_this import Array

        result = Array.new()
        self.cache_conversion(pyobj, result)
        for idx, val in enumerate(pyobj):
            converted = self.convert(val)
            if converted is NoValue:
                self.post_process_list.append((result, idx, id(val)))
            result.push(converted)
        return result

    def convert_dict(self, pyobj):
        from jstypes.global_this import Array

        # Temporarily store NoValue in the cache since we only get the
        # actual value from dict_converter. We'll replace these with the
        # correct values in the postprocess step
        self.cache_conversion(pyobj, NoValue)
        pairs = Array.new()
        for [key, value] in pyobj.items():
            converted = self.convert(value)
            if converted is NoValue:
                self.post_process_list.append((pairs, key, id(value)))
            pairs.push(Array.new(key, converted))
        result = self.dict_converter(pairs)
        self.pairs_to_dict_map[pairs.js_id] = result
        # Update the cache to point to the actual result
        self.cache_conversion(pyobj, result)
        return result

    def convert_set(self, pyobj):
        from jstypes.global_this import Set
        result = Set.new()
        self.cache_conversion(pyobj, result)
        for key in pyobj:
            if isinstance(key, JSProxy):
                raise ConversionError(
                    f"Cannot use {key!r} as a key for a JavaScript Set"
                )
            result.add(key)
        return result

jstypes.global_this 모듈

jstypes.global_this 모듈을 사용하면 JavaScript에서 객체를 임포트할 수 있습니다. 정의는 다음과 같습니다:

import sys
from jstypes.code import run_js
from jstypes.ffi import JSProxy
from importlib.abc import Loader, MetaPathFinder
from importlib.util import spec_from_loader

class JSLoader(Loader):
    def __init__(self, jsproxy):
        self.jsproxy = jsproxy

    def create_module(self, spec):
        return self.jsproxy

    def exec_module(self, module):
        pass

    def is_package(self, fullname):
        return True

class JSFinder(MetaPathFinder):
    def _get_object(self, fullname):
        [parent, _, child] = fullname.rpartition(".")
        if not parent:
            if child == "jstypes":
                return run_js("globalThis")
            return None

        parent_module = sys.modules[parent]
        if not isinstance(parent_module, JSProxy):
            # Not one of us.
            return None
        jsproxy = getattr(parent_module, child, None)
        if not isinstance(jsproxy, JSProxy):
            raise ModuleNotFoundError(f"No module named {fullname!r}", name=fullname)
        return jsproxy

    def find_spec(
        self,
        fullname,
        path,
        target,
    ):
        jsproxy = self._get_object(fullname)
        loader = JSLoader(jsproxy)
        return spec_from_loader(fullname, loader, origin="javascript")


finder = JSFinder()
sys.meta_path.insert(0, finder)
del sys.modules["jstypes.global_this"]
import jstypes.global_this
sys.meta_path.remove(finder)
sys.meta_path.append(finder)

jstypes 패키지

이 패키지에는 비어 있는 __init__.py와 두 개의 하위 모듈이 있습니다.

jstypes.ffi 모듈

다음과 같은 속성이 있습니다:

create_proxy(x): create_jsproxy(createPyProxy(x))를 반환합니다.

jsnull: JavaScript null 값과 상호 변환되는 특수 값입니다.

JSNull: jsnull의 타입입니다.

def destroy_proxies(proxies):
    for proxy in proxies:
        proxy.destroy()

to_js: 깊은 변환에 관한 절의 정의를 참조하십시오.

JSArray: type(run_js("[]"))입니다.

JSCallable: type(run_js("() => {}"))입니다.

JSDoubleProxy: type(create_proxy({}))입니다.

JSException: type(run_js("new Error()"))입니다.

JSGenerator: type(run_js("(function*(){})()"))입니다.

JSIterable: type(run_js("({[Symbol.iterator](){}})"))입니다.

JSIterator: type(run_js("({next(){}})"))입니다.

JSMap: type(run_js("({get(){}})"))입니다.

JSMutableMap: type(run_js("new Map()"))입니다.

JSProxy: type(run_js("({})"))입니다

JSBigInt: 다음과 같이 정의됩니다:

def _int_to_bigint(x):
    if isinstance(x, int):
        return JSBigInt(x)
    return x

class JSBigInt(int):
    # unary ops
    def __abs__(self):
        return JSBigInt(int.__abs__(self))

    def __invert__(self):
        return JSBigInt(int.__invert__(self))

    def __neg__(self):
        return JSBigInt(int.__neg__(self))

    def __pos__(self):
        return JSBigInt(int.__pos__(self))

    # binary ops
    def __add__(self, other):
        return _int_to_bigint(int.__add__(self, other))

    def __and__(self, other):
        return _int_to_bigint(int.__and__(self, other))

    def __floordiv__(self, other):
        return _int_to_bigint(int.__floordiv__(self, other))

    def __lshift__(self, other):
        return _int_to_bigint(int.__lshift__(self, other))

    def __mod__(self, other):
        return _int_to_bigint(int.__mod__(self, other))

    def __or__(self, other):
        return _int_to_bigint(int.__or__(self, other))

    def __pow__(self, other, modulus = None):
        return _int_to_bigint(int.__pow__(self, other, modulus))

    def __rshift__(self, other):
        return _int_to_bigint(int.__rshift__(self, other))

    def __sub__(self, other):
        return _int_to_bigint(int.__sub__(self, other))

    def __xor__(self, other):
        return _int_to_bigint(int.__xor__(self, other))

jstypes.code 모듈

run_js 함수를 노출합니다.

json 모듈의 변경 사항

json 모듈은 jsnull을 null로 직렬화하도록 갱신됩니다.

하위 호환성

이 제안은 새로운 API를 추가하기만 합니다. Pyodide에는 하위 호환성 문제가 있습니다. Pyodide와 비교하여 여러 모듈과 타입의 이름을 변경했습니다:

  1. pyodide 패키지는 jstypes로 변경됩니다
  2. js 모듈은 jstypes.global_this로 변경됩니다
  3. 모든 JSProxy 변형은 JSProxy처럼 대문자로 표기됩니다.

다음 릴리스에서 Pyodide는 변경된 이름과 원래 이름을 모두 지원할 것입니다. 또한 이전 이름에 대한 하위 호환성 심(shim)을 포함하는 패키지를 PyPI에 업로드할 것입니다.

보안 영향

Python을 실행할 수 있는, 완전히 샌드박스화된 몇 안 되는 플랫폼 중 하나에 대한 지원을 개선합니다.

교육 방법

참조 구현

Pyodide, https://github.com/hoodmane/cpython/tree/js-ffi

감사의 글

Mike Droettboom, Roman Yurchak, Gyeongjae Choi, Andrea Giammarchi