diff --git a/.github/codeql/codeql_config.yaml b/.github/codeql/codeql_config.yaml deleted file mode 100644 index 96b2a6f..0000000 --- a/.github/codeql/codeql_config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -name: "CodeQL config" -paths-ignore: - - third_party - - tests - - lib diff --git a/.github/codeql/codeql_config.yml b/.github/codeql/codeql_config.yml new file mode 100644 index 0000000..96de136 --- /dev/null +++ b/.github/codeql/codeql_config.yml @@ -0,0 +1,3 @@ +name: "CodeQL config" +paths: + - 'python-webrtc' diff --git a/.github/workflows/black.yml b/.github/workflows/black.yml new file mode 100644 index 0000000..598dfdb --- /dev/null +++ b/.github/workflows/black.yml @@ -0,0 +1,22 @@ +name: Black + +on: [push, pull_request] + +jobs: + black: + name: Black + runs-on: ubuntu-latest + + steps: + - name: Checkout repository. + uses: actions/checkout@v2 + + - name: Setup Python. + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Check code style. + run: | + pip install black + black --check python-webrtc diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..df2ea54 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,29 @@ +name: Build + +on: + pull_request: + branches: + - main + schedule: + - cron: '0 8 * * 1' + +jobs: + build: + name: Build + runs-on: ubuntu-latest + + steps: + - name: Checkout repository. + uses: actions/checkout@v2 + with: + submodules: recursive + + - name: Setup Python. + uses: actions/setup-python@v2 + with: + python-version: 3.9 + + - name: Build. + run: | + sudo apt install -y python3.9-dev + python setup.py build diff --git a/.github/workflows/codeql_analysis.yml b/.github/workflows/codeql_analysis.yml index e05af9a..5079fba 100644 --- a/.github/workflows/codeql_analysis.yml +++ b/.github/workflows/codeql_analysis.yml @@ -2,10 +2,7 @@ name: CodeQL on: push: - branches: [ main ] pull_request: - # The branches below must be a subset of the branches above - branches: [ main ] schedule: - cron: '0 8 * * 1' @@ -19,31 +16,36 @@ jobs: strategy: fail-fast: false matrix: - language: [ 'cpp', 'python' ] + language: [ + # 'cpp', + 'python' + ] steps: - - name: Checkout repository + - name: Checkout repository. uses: actions/checkout@v2 with: submodules: recursive - - name: Setup Python - uses: actions/setup-python@v1 - with: - python-version: 3.9 - - - name: Initialize CodeQL + - name: Initialize CodeQL. uses: github/codeql-action/init@v1 with: languages: ${{ matrix.language }} - config-file: ./.github/codeql/codeql_config.yaml + setup-python-dependencies: false + config-file: ./.github/codeql/codeql_config.yml + + - name: Setup Python. + if: matrix.language == 'cpp' + uses: actions/setup-python@v2 + with: + python-version: 3.9 - - if: matrix.language == 'cpp' - name: Build + - name: Build. + if: matrix.language == 'cpp' run: | sudo apt install -y python3.9-dev - export NO_LTO=true python setup.py build - - name: Perform CodeQL Analysis + # it takes too long for cpp + - name: Perform CodeQL Analysis. uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/create_sdist.yml b/.github/workflows/create_sdist.yml index 1cb2791..8e70850 100644 --- a/.github/workflows/create_sdist.yml +++ b/.github/workflows/create_sdist.yml @@ -3,7 +3,6 @@ name: Create and publish Source Distribution on: push: branches: - - main - pypi-dev paths: - '.github/workflows/create_sdist.yml' @@ -23,7 +22,7 @@ jobs: submodules: recursive - name: Setup Python. - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: 3.7 diff --git a/CMakeLists.txt b/CMakeLists.txt index 7ebf98f..7410477 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,9 +5,9 @@ include(ExternalProject) project(python_webrtc LANGUAGES C CXX - DESCRIPTION "a Python Extension that provides bindings to WebRTC" + DESCRIPTION "a Python extension that provides bindings to WebRTC" HOMEPAGE_URL "https://github.com/MarshalX/python-webrtc" - VERSION "0.0.0.5" + VERSION "0.0.0.6" ) include(ExternalProject) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..25b45c8 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,9 @@ +# Security Policy + +## Supported Versions + +Only the latest published version on [PyPI](https://pypi.org/project/wrtc/#history) supported with security updates. + +## Reporting a Vulnerability + +Email for reports: ilya@marshal.dev diff --git a/docs/links/wrtc/__init__.py b/docs/links/wrtc/__init__.py index 00b2860..4c11a3e 100644 --- a/docs/links/wrtc/__init__.py +++ b/docs/links/wrtc/__init__.py @@ -3,12 +3,17 @@ import typing __all__ = [ + "CallbackPythonWebRTCException", "MediaStream", "MediaStreamSourceState", "MediaStreamTrack", "MediaStreamTrackState", "PeerConnectionFactory", + "PythonWebRTCException", + "PythonWebRTCExceptionBase", "RTCAudioSource", + "RTCCallbackException", + "RTCException", "RTCIceConnectionState", "RTCIceGatheringState", "RTCOnDataEvent", @@ -18,6 +23,7 @@ "RTCSdpType", "RTCSessionDescription", "RTCSessionDescriptionInit", + "SdpParseException", "answer", "checking", "closed", @@ -42,6 +48,9 @@ ] +class CallbackPythonWebRTCException(): + def what(self) -> str: ... + pass class MediaStream(): def addTrack(self, arg0: MediaStreamTrack) -> None: ... def clone(self) -> MediaStream: ... @@ -170,11 +179,20 @@ def getOrCreateDefault() -> PeerConnectionFactory: ... @staticmethod def release() -> None: ... pass +class PythonWebRTCExceptionBase(Exception, BaseException): + pass +class PythonWebRTCException(PythonWebRTCExceptionBase, Exception, BaseException): + pass class RTCAudioSource(): def __init__(self) -> None: ... def createTrack(self) -> MediaStreamTrack: ... def onData(self, arg0: RTCOnDataEvent) -> None: ... pass +class RTCCallbackException(): + def what(self) -> str: ... + pass +class RTCException(PythonWebRTCExceptionBase, Exception, BaseException): + pass class RTCIceConnectionState(): """ Members: @@ -306,10 +324,10 @@ def __init__(self) -> None: ... @staticmethod def addTrack(*args, **kwargs) -> typing.Any: ... def close(self) -> None: ... - def createAnswer(self, arg0: typing.Callable[[RTCSessionDescription], None]) -> None: ... - def createOffer(self, arg0: typing.Callable[[RTCSessionDescription], None]) -> None: ... - def setLocalDescription(self, arg0: typing.Callable[[], None], arg1: RTCSessionDescription) -> None: ... - def setRemoteDescription(self, arg0: typing.Callable[[], None], arg1: RTCSessionDescription) -> None: ... + def createAnswer(self, arg0: typing.Callable[[RTCSessionDescription], None], arg1: typing.Callable[[CallbackPythonWebRTCException], None]) -> None: ... + def createOffer(self, arg0: typing.Callable[[RTCSessionDescription], None], arg1: typing.Callable[[CallbackPythonWebRTCException], None]) -> None: ... + def setLocalDescription(self, arg0: typing.Callable[[], None], arg1: typing.Callable[[CallbackPythonWebRTCException], None], arg2: RTCSessionDescription) -> None: ... + def setRemoteDescription(self, arg0: typing.Callable[[], None], arg1: typing.Callable[[CallbackPythonWebRTCException], None], arg2: RTCSessionDescription) -> None: ... pass class RTCPeerConnectionState(): """ @@ -430,6 +448,8 @@ def type(self) -> RTCSdpType: def type(self, arg0: RTCSdpType) -> None: pass pass +class SdpParseException(PythonWebRTCExceptionBase, Exception, BaseException): + pass def getUserMedia() -> MediaStream: pass def ping() -> None: diff --git a/examples/telegram_group_calls.py b/examples/telegram_group_calls.py index 952d60f..8aad4ce 100644 --- a/examples/telegram_group_calls.py +++ b/examples/telegram_group_calls.py @@ -20,7 +20,7 @@ def parse_sdp(sdp): def lookup(prefix): for line in lines: if line.startswith(prefix): - return line[len(prefix):] + return line[len(prefix) :] info = { 'fingerprint': lookup('a=fingerprint:').split(' ')[1], @@ -38,17 +38,11 @@ def lookup(prefix): def get_params_from_parsed_sdp(info): return { - 'fingerprints': [ - { - 'fingerprint': info['fingerprint'], - 'hash': info['hash'], - 'setup': 'active' - } - ], + 'fingerprints': [{'fingerprint': info['fingerprint'], 'hash': info['hash'], 'setup': 'active'}], 'pwd': info['pwd'], 'ssrc': info['source'], 'ssrc-groups': [], - 'ufrag': info['ufrag'] + 'ufrag': info['ufrag'], } @@ -56,9 +50,11 @@ def build_answer(sdp): def add_candidates(): candidates_sdp = [] for cand in sdp['transport']['candidates']: - candidates_sdp.append(f"a=candidate:{cand['foundation']} {cand['component']} {cand['protocol']} " - f"{cand['priority']} {cand['ip']} {cand['port']} typ {cand['type']} " - f"generation {cand['generation']}") + candidates_sdp.append( + f"a=candidate:{cand['foundation']} {cand['component']} {cand['protocol']} " + f"{cand['priority']} {cand['ip']} {cand['port']} typ {cand['type']} " + f"generation {cand['generation']}" + ) return '\n'.join(candidates_sdp) @@ -114,11 +110,11 @@ def get_ms_time(): if last_read_ms == 0 or start_time - last_read_ms >= 10: data = f.read(length) - if not data: # eof + if not data: # eof f.close() break - event_data = webrtc.RTCOnDataEvent(data, length // 4) # 2 channels + event_data = webrtc.RTCOnDataEvent(data, length // 4) # 2 channels event_data.channel_count = 2 audio_source.on_data(event_data) @@ -140,9 +136,7 @@ async def main(client, input_peer, input_filename): await pc.set_local_description(local_sdp) app = PyrogramBridge(client) - app.register_group_call_native_callback( - group_call_participants_update_callback, group_call_update_callback - ) + app.register_group_call_native_callback(group_call_participants_update_callback, group_call_update_callback) await app.get_and_set_group_call(input_peer) await app.resolve_and_set_join_as(None) @@ -159,9 +153,7 @@ def pre_update_processing(): # await asyncio.wait_for(REMOTE_ANSWER_EVENT.wait(), 30) await pc.set_remote_description( - webrtc.RTCSessionDescription( - webrtc.RTCSessionDescriptionInit(webrtc.RTCSdpType.answer, remote_sdp) - ) + webrtc.RTCSessionDescription(webrtc.RTCSessionDescriptionInit(webrtc.RTCSdpType.answer, remote_sdp)) ) thread = threading.Thread(target=send_audio_data, args=(audio_source, input_filename)) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..eaf2bd5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[tool.black] +line-length = 120 +skip-string-normalization=true +target-version = ['py37'] +include = '\.pyi?$' +exclude = ''' +( + /( + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | _build + | buck-out + | build + | dist + )/ +) +''' diff --git a/python-webrtc/cpp/CMakeLists.txt b/python-webrtc/cpp/CMakeLists.txt index 9ee1d99..eb3c7a4 100644 --- a/python-webrtc/cpp/CMakeLists.txt +++ b/python-webrtc/cpp/CMakeLists.txt @@ -2,7 +2,7 @@ configure_file("${src_loc}/config.h.in" "${src_loc}/config.h") file(GLOB_RECURSE MODULE_SRC ${src_loc}/*.cpp ${src_loc}/*.h) -if ("$ENV{NO_LTO}" STREQUAL "true") +if(UNIX AND NOT APPLE) set(CMAKE_INTERPROCEDURAL_OPTIMIZATION OFF) endif() diff --git a/python-webrtc/cpp/src/exceptions.cpp b/python-webrtc/cpp/src/exceptions.cpp new file mode 100644 index 0000000..aa53ab5 --- /dev/null +++ b/python-webrtc/cpp/src/exceptions.cpp @@ -0,0 +1,53 @@ +// +// Copyright 2022 Il`ya (Marshal) . All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE.md file in the root of the project. +// + +#include "exceptions.h" + +namespace python_webrtc { + + const char *CallbackPythonWebRTCException::what() const noexcept { + return _msg.c_str(); + } + + [[nodiscard]] const char *PythonWebRTCException::what() const noexcept { + return _msg.c_str(); + } + + void Exceptions::Init(pybind11::module &m) { + pybind11::class_(m, "CallbackPythonWebRTCException") + .def("what", &CallbackPythonWebRTCException::what); + pybind11::class_(m, "RTCCallbackException") + .def("what", &RTCCallbackException::what); + + static pybind11::exception baseExc(m, "PythonWebRTCExceptionBase"); + + pybind11::register_exception(m, "PythonWebRTCException", baseExc); + pybind11::register_exception(m, "RTCException", baseExc); + pybind11::register_exception(m, "SdpParseException", baseExc); + } + + RTCException wrapRTCError(const webrtc::RTCError &error) { + std::string msg; + return RTCException(msg + "[" + ToString(error.type()) + "] " + error.message()); + } + + RTCCallbackException wrapRTCErrorForCallback(const webrtc::RTCError &error) { + std::string msg; + return RTCCallbackException(msg + "[" + ToString(error.type()) + "] " + error.message()); + } + + SdpParseException wrapSdpParseError(const webrtc::SdpParseError &error) { + std::string msg; + + if (error.line.empty()) { + return SdpParseException(msg + error.description); + } else { + return SdpParseException(msg + "Line: " + error.line + ". " + error.description); + } + } + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/exceptions.h b/python-webrtc/cpp/src/exceptions.h new file mode 100644 index 0000000..c0b407a --- /dev/null +++ b/python-webrtc/cpp/src/exceptions.h @@ -0,0 +1,60 @@ +// +// Copyright 2022 Il`ya (Marshal) . All rights reserved. +// +// Use of this source code is governed by a BSD-style license +// that can be found in the LICENSE.md file in the root of the project. +// + +#pragma once + +#include + +#include +#include + +namespace python_webrtc { + + class CallbackPythonWebRTCException { + public: + explicit CallbackPythonWebRTCException(std::string msg) : _msg(std::move(msg)) {} + + [[nodiscard]] const char *what() const noexcept; + + private: + std::string _msg; + }; + + class PythonWebRTCException : public std::exception { + public: + explicit PythonWebRTCException(std::string msg) : _msg(std::move(msg)) {} + + [[nodiscard]] const char *what() const noexcept override; + + private: + std::string _msg; + }; + + class RTCException : public PythonWebRTCException { + using PythonWebRTCException::PythonWebRTCException; + }; + + class SdpParseException : public PythonWebRTCException { + using PythonWebRTCException::PythonWebRTCException; + }; + + class RTCCallbackException : public CallbackPythonWebRTCException { + using CallbackPythonWebRTCException::CallbackPythonWebRTCException; + }; + + RTCException wrapRTCError(const webrtc::RTCError &error); + + SdpParseException wrapSdpParseError(const webrtc::SdpParseError &error); + + RTCCallbackException wrapRTCErrorForCallback(const webrtc::RTCError &error); + + class Exceptions { + public: + static void Init(pybind11::module &m); + }; + +} // namespace python_webrtc diff --git a/python-webrtc/cpp/src/interfaces/create_session_description_observer.cpp b/python-webrtc/cpp/src/interfaces/create_session_description_observer.cpp index 536d234..aac67e5 100644 --- a/python-webrtc/cpp/src/interfaces/create_session_description_observer.cpp +++ b/python-webrtc/cpp/src/interfaces/create_session_description_observer.cpp @@ -9,12 +9,6 @@ namespace python_webrtc { - CreateSessionDescriptionObserver::CreateSessionDescriptionObserver( - RTCPeerConnection *peer_connection, std::function &on_success) { - _on_success = on_success; - _peer_connection = peer_connection; - } - void CreateSessionDescriptionObserver::OnSuccess(webrtc::SessionDescriptionInterface *description) { // TODO // ref: https://developer.mozilla.org/en-US/docs/Web/API/RTCSessionDescription/RTCSessionDescription @@ -22,13 +16,13 @@ namespace python_webrtc { // and other methods which take SDP as input now directly accept an object conforming to the // RTCSessionDescriptionInit dictionary, so you don't have to instantiate an RTCSessionDescription yourself. - _peer_connection->SaveLastSdp(RTCSessionDescriptionInit::Wrap(description)); - _on_success(RTCSessionDescription::Wrap(description)); + _peerConnection->SaveLastSdp(RTCSessionDescriptionInit::Wrap(description)); + _onSuccess(RTCSessionDescription::Wrap(description)); delete description; } - void CreateSessionDescriptionObserver::OnFailure(webrtc::RTCError) { -// TODO call onFail + void CreateSessionDescriptionObserver::OnFailure(webrtc::RTCError error) { + _onFailure(wrapRTCErrorForCallback(error)); } } diff --git a/python-webrtc/cpp/src/interfaces/create_session_description_observer.h b/python-webrtc/cpp/src/interfaces/create_session_description_observer.h index 16a2582..7c2d692 100644 --- a/python-webrtc/cpp/src/interfaces/create_session_description_observer.h +++ b/python-webrtc/cpp/src/interfaces/create_session_description_observer.h @@ -15,17 +15,19 @@ namespace python_webrtc { class CreateSessionDescriptionObserver : public webrtc::CreateSessionDescriptionObserver { public: - explicit CreateSessionDescriptionObserver(RTCPeerConnection *peer_connection, - std::function &on_success); + CreateSessionDescriptionObserver(RTCPeerConnection *peerConnection, + std::function &onSuccess, + std::function &onFailure) : + _peerConnection(peerConnection), _onSuccess(onSuccess), _onFailure(onFailure) {} void OnSuccess(webrtc::SessionDescriptionInterface *) override; void OnFailure(webrtc::RTCError) override; private: - RTCPeerConnection *_peer_connection; - std::function _on_success = nullptr; - std::function _on_error = nullptr; + RTCPeerConnection *_peerConnection; + std::function _onSuccess = nullptr; + std::function _onFailure = nullptr; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp index 232ea9e..7b3a012 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.cpp @@ -40,8 +40,7 @@ namespace python_webrtc { configuration, std::move(dependencies)); if (!result.ok()) { - // TODO raise smth - return; + throw wrapRTCError(result.error()); } _jinglePeerConnection = result.MoveValue(); @@ -79,15 +78,18 @@ namespace python_webrtc { _lastSdp = lastSdp; } - void RTCPeerConnection::CreateOffer(std::function &onSuccess) { + void RTCPeerConnection::CreateOffer( + std::function &onSuccess, + std::function &onFailure) { if (!_jinglePeerConnection || _jinglePeerConnection->signaling_state() == webrtc::PeerConnectionInterface::SignalingState::kClosed) { -// TODO call onFail -// "Failed to execute 'createOffer' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'." + onFailure(CallbackPythonWebRTCException( + "Failed to execute 'createOffer' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'." + )); return; } - auto observer = new rtc::RefCountedObject(this, onSuccess); + auto observer = new rtc::RefCountedObject(this, onSuccess, onFailure); // TODO bind RTCOfferOptions (voice_activity_detection, iceRestart, offerToReceiveAudio, offerToReceiveVideo) auto options = webrtc::PeerConnectionInterface::RTCOfferAnswerOptions(); @@ -97,21 +99,26 @@ namespace python_webrtc { _jinglePeerConnection->CreateOffer(observer, options); } - void RTCPeerConnection::CreateAnswer(std::function &onSuccess) { + void RTCPeerConnection::CreateAnswer( + std::function &onSuccess, + std::function &onFailure) { if (!_jinglePeerConnection || _jinglePeerConnection->signaling_state() == webrtc::PeerConnectionInterface::SignalingState::kClosed) { -// TODO call onFail -// "Failed to execute 'createAnswer' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'." + onFailure(CallbackPythonWebRTCException( + "Failed to execute 'createAnswer' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'.")); return; } - auto observer = new rtc::RefCountedObject(this, onSuccess); + auto observer = new rtc::RefCountedObject(this, onSuccess, onFailure); // TODO bind RTCAnswerOptions (voice_activity_detection) auto options = webrtc::PeerConnectionInterface::RTCOfferAnswerOptions(); _jinglePeerConnection->CreateAnswer(observer, options); } - void RTCPeerConnection::SetLocalDescription(std::function &onSuccess, RTCSessionDescription &description) { + void RTCPeerConnection::SetLocalDescription( + std::function &onSuccess, + std::function &onFailure, + RTCSessionDescription &description) { // TODO accept RTCSessionDescriptionInit too if (description.getSdp().empty()) { // TODO use lastSdp @@ -123,16 +130,19 @@ namespace python_webrtc { if (!_jinglePeerConnection || _jinglePeerConnection->signaling_state() == webrtc::PeerConnectionInterface::SignalingState::kClosed) { -// TODO call onFail -// "Failed to execute 'setLocalDescription' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'." + onFailure(CallbackPythonWebRTCException( + "Failed to execute 'setLocalDescription' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'.")); return; } - auto observer = new rtc::RefCountedObject(onSuccess); + auto observer = new rtc::RefCountedObject(onSuccess, onFailure); _jinglePeerConnection->SetLocalDescription(observer, raw_description_ptr.release()); } - void RTCPeerConnection::SetRemoteDescription(std::function &onSuccess, RTCSessionDescription &description) { + void RTCPeerConnection::SetRemoteDescription( + std::function &onSuccess, + std::function &onFailure, + RTCSessionDescription &description) { // TODO accept RTCSessionDescriptionInit too auto *raw_description = static_cast(description); @@ -140,21 +150,19 @@ namespace python_webrtc { if (!_jinglePeerConnection || _jinglePeerConnection->signaling_state() == webrtc::PeerConnectionInterface::SignalingState::kClosed) { -// TODO call onFail -// "Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'." + onFailure(CallbackPythonWebRTCException( + "Failed to execute 'setRemoteDescription' on 'RTCPeerConnection': The RTCPeerConnection's signalingState is 'closed'.")); return; } - auto observer = new rtc::RefCountedObject(onSuccess); + auto observer = new rtc::RefCountedObject(onSuccess, onFailure); _jinglePeerConnection->SetRemoteDescription(observer, raw_description_ptr.release()); } RTCRtpSender *RTCPeerConnection::AddTrack( MediaStreamTrack &mediaStreamTrack, const std::vector &mediaStreams) { if (!_jinglePeerConnection) { - // TODO raise - // "Cannot addTrack; RTCPeerConnection is closed" - return {}; + throw PythonWebRTCException("Cannot add track; RTCPeerConnection is closed"); } std::vector streamIds; @@ -165,9 +173,7 @@ namespace python_webrtc { auto result = _jinglePeerConnection->AddTrack(mediaStreamTrack.track(), streamIds); if (!result.ok()) { - // TODO raise - // result.error() // RTCError - return {}; + throw wrapRTCError(result.error()); } auto rtpSender = result.value(); @@ -177,9 +183,7 @@ namespace python_webrtc { RTCRtpSender *RTCPeerConnection::AddTrack( MediaStreamTrack &mediaStreamTrack, std::optional> mediaStream) { if (!_jinglePeerConnection) { - // TODO raise - // "Cannot addTrack; RTCPeerConnection is closed" - return {}; + throw PythonWebRTCException("Cannot add track; RTCPeerConnection is closed"); } std::vector streamIds; @@ -189,9 +193,7 @@ namespace python_webrtc { auto result = _jinglePeerConnection->AddTrack(mediaStreamTrack.track(), streamIds); if (!result.ok()) { - // TODO raise - // result.error() // RTCError - return {}; + throw wrapRTCError(result.error()); } auto rtpSender = result.value(); @@ -201,12 +203,12 @@ namespace python_webrtc { void RTCPeerConnection::Close() { if (_jinglePeerConnection) { _jinglePeerConnection->Close(); - } - if (_jinglePeerConnection->GetConfiguration().sdp_semantics == webrtc::SdpSemantics::kUnifiedPlan) { - for (const auto &transceiver: _jinglePeerConnection->GetTransceivers()) { - auto track = MediaStreamTrack::holder()->GetOrCreate(_factory, transceiver->receiver()->track()); - track->OnPeerConnectionClosed(); + if (_jinglePeerConnection->GetConfiguration().sdp_semantics == webrtc::SdpSemantics::kUnifiedPlan) { + for (const auto &transceiver: _jinglePeerConnection->GetTransceivers()) { + auto track = MediaStreamTrack::holder()->GetOrCreate(_factory, transceiver->receiver()->track()); + track->OnPeerConnectionClosed(); + } } } diff --git a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h index 537f30c..24f27f4 100644 --- a/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h +++ b/python-webrtc/cpp/src/interfaces/rtc_peer_connection.h @@ -13,6 +13,7 @@ #include #include +#include "../exceptions.h" #include "../models/python_webrtc/rtc_session_description.h" #include "media_stream_track.h" @@ -33,13 +34,17 @@ namespace python_webrtc { ~RTCPeerConnection() override; - void CreateOffer(std::function &); + void CreateOffer( + std::function &, std::function &); - void CreateAnswer(std::function &); + void CreateAnswer( + std::function &, std::function &); - void SetLocalDescription(std::function &, RTCSessionDescription &); + void SetLocalDescription( + std::function &, std::function &, RTCSessionDescription &); - void SetRemoteDescription(std::function &, RTCSessionDescription &); + void SetRemoteDescription( + std::function &, std::function &, RTCSessionDescription &); RTCRtpSender *AddTrack(MediaStreamTrack &, std::optional>); diff --git a/python-webrtc/cpp/src/interfaces/set_session_description_observer.cpp b/python-webrtc/cpp/src/interfaces/set_session_description_observer.cpp index c037019..a0dd4a1 100644 --- a/python-webrtc/cpp/src/interfaces/set_session_description_observer.cpp +++ b/python-webrtc/cpp/src/interfaces/set_session_description_observer.cpp @@ -10,11 +10,11 @@ namespace python_webrtc { void SetSessionDescriptionObserver::OnSuccess() { - _on_success(); + _onSuccess(); } - void SetSessionDescriptionObserver::OnFailure(webrtc::RTCError) { -// TODO call onFail + void SetSessionDescriptionObserver::OnFailure(webrtc::RTCError error) { + _onFailure(wrapRTCErrorForCallback(error)); } } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/interfaces/set_session_description_observer.h b/python-webrtc/cpp/src/interfaces/set_session_description_observer.h index c20585b..f50a82f 100644 --- a/python-webrtc/cpp/src/interfaces/set_session_description_observer.h +++ b/python-webrtc/cpp/src/interfaces/set_session_description_observer.h @@ -15,15 +15,18 @@ namespace python_webrtc { class SetSessionDescriptionObserver : public webrtc::SetSessionDescriptionObserver { public: - explicit SetSessionDescriptionObserver(std::function &on_success) : _on_success(on_success) {} + SetSessionDescriptionObserver( + std::function &onSuccess, + std::function &onFailure) : + _onSuccess(onSuccess), _onFailure(onFailure) {} void OnSuccess() override; void OnFailure(webrtc::RTCError) override; private: - std::function _on_success = nullptr; - std::function _on_error = nullptr; + std::function _onSuccess = nullptr; + std::function _onFailure = nullptr; }; } // namespace python_webrtc diff --git a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp index 77a4a10..69a99fa 100644 --- a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp +++ b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description.cpp @@ -7,13 +7,15 @@ #include "rtc_session_description.h" +#include "../../exceptions.h" + namespace python_webrtc { RTCSessionDescription::RTCSessionDescription(const RTCSessionDescriptionInit &init) { webrtc::SdpParseError error; auto description = webrtc::CreateSessionDescription(init.type, init.sdp, &error); if (!description) { - // TODO throw exception with error + throw wrapSdpParseError(error); } _description = std::move(description); diff --git a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp index 4cbd8fd..e04ccdf 100644 --- a/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp +++ b/python-webrtc/cpp/src/models/python_webrtc/rtc_session_description_init.cpp @@ -9,7 +9,7 @@ namespace python_webrtc { - RTCSessionDescriptionInit::RTCSessionDescriptionInit() {} + RTCSessionDescriptionInit::RTCSessionDescriptionInit() = default; RTCSessionDescriptionInit::RTCSessionDescriptionInit(webrtc::SdpType type, std::string sdp) : type(type), sdp(std::move(sdp)) {} @@ -25,7 +25,7 @@ namespace python_webrtc { std::string sdp; description->ToString(&sdp); - return RTCSessionDescriptionInit(description->GetType(), sdp); + return {description->GetType(), sdp}; } } diff --git a/python-webrtc/cpp/src/module.cpp b/python-webrtc/cpp/src/module.cpp index 29fd24e..740a037 100644 --- a/python-webrtc/cpp/src/module.cpp +++ b/python-webrtc/cpp/src/module.cpp @@ -8,6 +8,7 @@ #include #include "config.h" +#include "exceptions.h" #include "enums/enums.h" #include "models/models.h" #include "interfaces/interfaces.h" @@ -33,6 +34,7 @@ PYBIND11_MODULE(wrtc, m) { m.def("ping", &ping); + python_webrtc::Exceptions::Init(m); python_webrtc::Enums::Init(m); python_webrtc::Models::Init(m); python_webrtc::Interfaces::Init(m); diff --git a/python-webrtc/python/test.py b/python-webrtc/python/test.py index d506f20..0c314cb 100644 --- a/python-webrtc/python/test.py +++ b/python-webrtc/python/test.py @@ -113,11 +113,29 @@ def get_dir(o): assert s1._native_obj != s2._native_obj local_sdp = await pc.create_offer() - print(local_sdp.sdp) + # print(local_sdp.sdp) + # pc.close() await pc.set_local_description(local_sdp) - # pc.close() + pc.close() + pc.close() + + try: + # invalid sdp + webrtc.RTCSessionDescription(webrtc.RTCSessionDescriptionInit(webrtc.RTCSdpType.answer, 'sdp')) + + # invalid pc state + pc.close() + + # sender already created + pc.add_track(tracks1[0], stream) + # except webrtc.PythonWebRTCExceptionBase as e: + # except webrtc.PythonWebRTCException as e: + except webrtc.RTCException as e: + # except webrtc.SdpParseException as e: + print('exception', str(e)) # idle() + if __name__ == '__main__': asyncio.run(main()) diff --git a/python-webrtc/python/tgcalls_test.py b/python-webrtc/python/tgcalls_test.py index 6cf6d10..3f4490a 100644 --- a/python-webrtc/python/tgcalls_test.py +++ b/python-webrtc/python/tgcalls_test.py @@ -39,7 +39,7 @@ def parse_sdp(sdp): def lookup(prefix): for line in lines: if line.startswith(prefix): - return line[len(prefix):] + return line[len(prefix) :] info = { 'fingerprint': lookup('a=fingerprint:').split(' ')[1], @@ -57,17 +57,11 @@ def lookup(prefix): def get_params_from_parsed_sdp(info): return { - 'fingerprints': [ - { - 'fingerprint': info['fingerprint'], - 'hash': info['hash'], - 'setup': 'active' - } - ], + 'fingerprints': [{'fingerprint': info['fingerprint'], 'hash': info['hash'], 'setup': 'active'}], 'pwd': info['pwd'], 'ssrc': info['source'], 'ssrc-groups': [], - 'ufrag': info['ufrag'] + 'ufrag': info['ufrag'], } @@ -75,9 +69,11 @@ def build_answer(sdp): def add_candidates(): candidates_sdp = [] for cand in sdp['transport']['candidates']: - candidates_sdp.append(f"a=candidate:{cand['foundation']} {cand['component']} {cand['protocol']} " - f"{cand['priority']} {cand['ip']} {cand['port']} typ {cand['type']} " - f"generation {cand['generation']}") + candidates_sdp.append( + f"a=candidate:{cand['foundation']} {cand['component']} {cand['protocol']} " + f"{cand['priority']} {cand['ip']} {cand['port']} typ {cand['type']} " + f"generation {cand['generation']}" + ) return '\n'.join(candidates_sdp) @@ -133,11 +129,11 @@ def get_ms_time(): if last_read_ms == 0 or start_time - last_read_ms >= 10: data = f.read(length) - if not data: # eof + if not data: # eof f.close() break - event_data = webrtc.RTCOnDataEvent(data, length // 4) # 2 channels + event_data = webrtc.RTCOnDataEvent(data, length // 4) # 2 channels event_data.channel_count = 2 audio_source.on_data(event_data) @@ -164,9 +160,7 @@ async def main(client, input_peer): await pc.set_local_description(local_sdp) app = PyrogramBridge(client) - app.register_group_call_native_callback( - group_call_participants_update_callback, group_call_update_callback - ) + app.register_group_call_native_callback(group_call_participants_update_callback, group_call_update_callback) await app.get_and_set_group_call(input_peer) await app.resolve_and_set_join_as(None) @@ -184,9 +178,7 @@ def pre_update_processing(): # TODO allow to pass RTCSessionDescriptionInit await pc.set_remote_description( - webrtc.RTCSessionDescription( - webrtc.RTCSessionDescriptionInit(webrtc.RTCSdpType.answer, remote_sdp) - ) + webrtc.RTCSessionDescription(webrtc.RTCSessionDescriptionInit(webrtc.RTCSdpType.answer, remote_sdp)) ) thread = threading.Thread(target=send_audio_data, args=(audio_source,)) diff --git a/python-webrtc/python/webrtc/__init__.py b/python-webrtc/python/webrtc/__init__.py index 44bcb6c..2d51f91 100644 --- a/python-webrtc/python/webrtc/__init__.py +++ b/python-webrtc/python/webrtc/__init__.py @@ -21,6 +21,13 @@ from .models.rtc_session_description import RTCSessionDescription from .models.rtc_on_data_event import RTCOnDataEvent +# exception +PythonWebRTCExceptionBase = wrtc.PythonWebRTCExceptionBase +PythonWebRTCException = wrtc.PythonWebRTCException +RTCException = wrtc.RTCException +SdpParseException = wrtc.SdpParseException + +# enums RTCPeerConnectionState = wrtc.RTCPeerConnectionState RTCIceConnectionState = wrtc.RTCIceConnectionState RTCIceGatheringState = wrtc.RTCIceGatheringState @@ -29,6 +36,11 @@ MediaStreamSourceState = wrtc.MediaStreamSourceState __all__ = [ + # exceptions + 'PythonWebRTCExceptionBase', + 'PythonWebRTCException', + 'RTCException', + 'SdpParseException', # enums 'RTCPeerConnectionState', 'RTCIceConnectionState', @@ -36,21 +48,17 @@ 'RTCSdpType', 'MediaStreamTrackState', 'MediaStreamSourceState', - # base 'WebRTCObject', - # interfaces 'RTCPeerConnection', 'MediaStreamTrack', 'MediaStream', 'RTCRtpSender', 'RTCAudioSource', - # functions 'getUserMedia', 'get_user_media', - # models 'RTCSessionDescriptionInit', 'RTCSessionDescription', diff --git a/python-webrtc/python/webrtc/interfaces/media_stream.py b/python-webrtc/python/webrtc/interfaces/media_stream.py index 54eb868..23bdf3d 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream.py @@ -19,6 +19,7 @@ class MediaStream(WebRTCObject): """The MediaStream interface represents a stream of media content. A stream consists of several tracks, such as video or audio tracks. Each track is specified as an instance of :obj:`webrtc.MediaStreamTrack`. """ + _class = wrtc.MediaStream @property @@ -41,15 +42,15 @@ def get_audio_tracks(self) -> List['webrtc.MediaStreamTrack']: def get_video_tracks(self) -> List['webrtc.MediaStreamTrack']: """Returns a :obj:`list` of the :obj:`webrtc.MediaStreamTrack` objects stored in the :obj:`webrtc.MediaStream` - object that have their kind attribute set to "video". The order is not defined, - and may not only vary from one machine to another, but also from one call to another. + object that have their kind attribute set to "video". The order is not defined, + and may not only vary from one machine to another, but also from one call to another. """ return MediaStreamTrack._wrap_many(self._native_obj.getVideoTracks()) def get_tracks(self) -> List['webrtc.MediaStreamTrack']: """Returns a :obj:`list` of all :obj:`webrtc.MediaStreamTrack` objects stored in the :obj:`webrtc.MediaStream` - object, regardless of the value of the kind attribute. The order is not defined, - and may not only vary from one machine to another, but also from one call to another. + object, regardless of the value of the kind attribute. The order is not defined, + and may not only vary from one machine to another, but also from one call to another. """ return MediaStreamTrack._wrap_many(self._native_obj.getTracks()) diff --git a/python-webrtc/python/webrtc/interfaces/media_stream_track.py b/python-webrtc/python/webrtc/interfaces/media_stream_track.py index 4e5189c..715e223 100644 --- a/python-webrtc/python/webrtc/interfaces/media_stream_track.py +++ b/python-webrtc/python/webrtc/interfaces/media_stream_track.py @@ -17,6 +17,7 @@ class MediaStreamTrack(WebRTCObject): """The MediaStreamTrack interface represents a single media track within a stream; typically, these are audio or video tracks, but other track types may exist as well. """ + _class = wrtc.RTCPeerConnection @property @@ -38,7 +39,7 @@ def id(self) -> str: @property def kind(self) -> str: """:obj:`str`: "audio" if the track is an audio track and to "video", if it is a video track. - It doesn't change if the track is deassociated from its source.""" + It doesn't change if the track is deassociated from its source.""" return self._native_obj.kind @property diff --git a/python-webrtc/python/webrtc/interfaces/rtc_audio_source.py b/python-webrtc/python/webrtc/interfaces/rtc_audio_source.py index f1ba2e9..9d5cac9 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_audio_source.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_audio_source.py @@ -17,6 +17,7 @@ class RTCAudioSource(WebRTCObject): """The :obj:`webrtc.MediaStreamTrack` interface represents a single media track within a stream; typically, these are audio or video tracks, but other track types may exist as well. """ + _class = wrtc.RTCAudioSource def create_track(self) -> 'webrtc.MediaStreamTrack': diff --git a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py index c498717..1a82a77 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_peer_connection.py @@ -10,7 +10,7 @@ import wrtc from webrtc import WebRTCObject -from webrtc.utils.callback_to_async import to_async +from webrtc.utils.callbacks_to_async import to_async if TYPE_CHECKING: import webrtc @@ -18,9 +18,10 @@ class RTCPeerConnection(WebRTCObject): """The RTCPeerConnection interface represents a WebRTC connection between the local computer and a remote peer. - It provides methods to connect to a remote peer, maintain and monitor the connection, and close the connection - once it's no longer needed. + It provides methods to connect to a remote peer, maintain and monitor the connection, and close the connection + once it's no longer needed. """ + _class = wrtc.RTCPeerConnection async def create_offer(self): @@ -31,6 +32,7 @@ async def create_offer(self): the configuration of an existing connection. """ from webrtc import RTCSessionDescription + return RTCSessionDescription._wrap(await to_async(self._native_obj.createOffer)) async def create_answer(self): @@ -39,6 +41,7 @@ async def create_answer(self): session, codecs and options supported by the machine, and any ICE candidates already gathered. """ from webrtc import RTCSessionDescription + return RTCSessionDescription._wrap(await to_async(self._native_obj.createAnswer)) # TODO arg should be RTCSessionDescriptionInit @@ -72,9 +75,9 @@ async def set_remote_description(self, sdp: 'webrtc.RTCSessionDescription'): return await to_async(self._native_obj.setRemoteDescription)(sdp._native_obj) def add_track( - self, - track: 'webrtc.MediaStreamTrack', - stream: Optional[Union['webrtc.MediaStream', List['webrtc.MediaStream']]] = None + self, + track: 'webrtc.MediaStreamTrack', + stream: Optional[Union['webrtc.MediaStream', List['webrtc.MediaStream']]] = None, ) -> 'webrtc.RTCRtpSender': """Adds a new :obj:`webrtc.MediaStreamTrack` to the set of tracks which will be transmitted to the other peer. @@ -97,6 +100,7 @@ def add_track( sender = self._native_obj.addTrack(track._native_obj, stream._native_obj) from webrtc import RTCRtpSender + return RTCRtpSender._wrap(sender) def close(self): diff --git a/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py b/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py index 06ef64b..015677a 100644 --- a/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py +++ b/python-webrtc/python/webrtc/interfaces/rtc_rtp_sender.py @@ -17,6 +17,7 @@ class RTCRtpSender(WebRTCObject): """The :obj:`webrtc.MediaStreamTrack` interface represents a single media track within a stream; typically, these are audio or video tracks, but other track types may exist as well. """ + _class = wrtc.RTCRtpSender @property diff --git a/python-webrtc/python/webrtc/models/rtc_on_data_event.py b/python-webrtc/python/webrtc/models/rtc_on_data_event.py index c26081c..e245ce3 100644 --- a/python-webrtc/python/webrtc/models/rtc_on_data_event.py +++ b/python-webrtc/python/webrtc/models/rtc_on_data_event.py @@ -17,6 +17,7 @@ class RTCOnDataEvent(WebRTCObject): Note: :obj:`webrtc.RTCOnDataEvent` should represent 10 ms of audio samples. """ + _class = wrtc.RTCOnDataEvent def __init__(self, audio_data: AnyStr, number_of_frames: int): diff --git a/python-webrtc/python/webrtc/models/rtc_session_description.py b/python-webrtc/python/webrtc/models/rtc_session_description.py index 875f900..bb66ea9 100644 --- a/python-webrtc/python/webrtc/models/rtc_session_description.py +++ b/python-webrtc/python/webrtc/models/rtc_session_description.py @@ -36,6 +36,7 @@ class RTCSessionDescription(WebRTCObject): Though some browsers might still support it, it may have already been removed from the relevant web standards, may be in the process of being dropped, or may only be kept for compatibility purposes. """ + _class = wrtc.RTCSessionDescription def __init__(self, rtc_session_description_init: 'webrtc.RTCSessionDescriptionInit'): diff --git a/python-webrtc/python/webrtc/models/rtc_session_description_init.py b/python-webrtc/python/webrtc/models/rtc_session_description_init.py index 05830ea..8e0e72b 100644 --- a/python-webrtc/python/webrtc/models/rtc_session_description_init.py +++ b/python-webrtc/python/webrtc/models/rtc_session_description_init.py @@ -16,6 +16,7 @@ class RTCSessionDescriptionInit(WebRTCObject): """An object providing the default values for the session description.""" + _class = wrtc.RTCSessionDescriptionInit def __init__(self, type: 'webrtc.RTCSdpType', sdp=''): diff --git a/python-webrtc/python/webrtc/utils/callback_to_async.py b/python-webrtc/python/webrtc/utils/callbacks_to_async.py similarity index 61% rename from python-webrtc/python/webrtc/utils/callback_to_async.py rename to python-webrtc/python/webrtc/utils/callbacks_to_async.py index 7a051c6..ac4e743 100644 --- a/python-webrtc/python/webrtc/utils/callback_to_async.py +++ b/python-webrtc/python/webrtc/utils/callbacks_to_async.py @@ -7,12 +7,12 @@ import asyncio +from .convert_exceptions import convert_from_callback_exception_to_exception -class _Event(asyncio.Event): +class _ThreadSafeEvent(asyncio.Event): def __init__(self): - # TODO - self.loop = asyncio.events._get_running_loop() + self.loop = asyncio.events.get_running_loop() super().__init__() def set(self): @@ -21,28 +21,32 @@ def set(self): class _AsyncWrapper: def __init__(self, func: callable): - self.__event = _Event() + self.__event = _ThreadSafeEvent() self.__func = func self.__args_for_run = [] self.__kwargs_for_run = {} - self.__result = None + self.__result = self.__error = None def set(self): self.__event.set() - def _on_success(self, result=None): # TODO many results mb + def _on_success(self, result=None): # TODO many results mb self.__result = result self.set() - def _on_fail(self, error): - # TODO reraise. error should be exception created from cpp side - pass + def _on_failure(self, error): + self.__error = error + self.set() async def run(self, timeout=10): - self.__func(self._on_success, *self.__args_for_run, **self.__kwargs_for_run) # TODO pass _on_fail + self.__func(self._on_success, self._on_failure, *self.__args_for_run, **self.__kwargs_for_run) await asyncio.wait_for(self.__event.wait(), timeout) + + if self.__error: + raise convert_from_callback_exception_to_exception(self.__error) + return self.__result def __call__(self, *args, **kwargs): diff --git a/python-webrtc/python/webrtc/utils/convert_exceptions.py b/python-webrtc/python/webrtc/utils/convert_exceptions.py new file mode 100644 index 0000000..4f78a56 --- /dev/null +++ b/python-webrtc/python/webrtc/utils/convert_exceptions.py @@ -0,0 +1,28 @@ +# +# Copyright 2022 Il`ya (Marshal) . All rights reserved. +# +# Use of this source code is governed by a BSD-style license +# that can be found in the LICENSE.md file in the root of the project. +# + +__CALLBACK_EXCEPTION_TO_EXCEPTION = None + + +def __init_callback_exception_to_exception_dict(): + global __CALLBACK_EXCEPTION_TO_EXCEPTION + + import wrtc + import webrtc + + __CALLBACK_EXCEPTION_TO_EXCEPTION = { + wrtc.CallbackPythonWebRTCException: webrtc.PythonWebRTCException, + wrtc.RTCCallbackException: webrtc.RTCException, + } + + +def convert_from_callback_exception_to_exception(callback_exception): + if not __CALLBACK_EXCEPTION_TO_EXCEPTION: + __init_callback_exception_to_exception_dict() + + cls = __CALLBACK_EXCEPTION_TO_EXCEPTION[type(callback_exception)] + return cls(callback_exception.what()) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..b3ca909 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +black \ No newline at end of file diff --git a/setup.py b/setup.py index 36adb49..e04f9ac 100644 --- a/setup.py +++ b/setup.py @@ -146,7 +146,7 @@ def build_extension(self, ext): author_email='ilya@marshal.dev', license='BSD 3-Clause', url='https://github.com/MarshalX/python-webrtc', - description='a Python Extension that provides bindings to WebRTC M92', + description='a Python extension that provides bindings to WebRTC M92', long_description=readme, long_description_content_type='text/markdown', classifiers=[