From 3091f8b9cfb5b78cda36755bd133cfbe16be50f3 Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 22 May 2025 07:46:55 -0400 Subject: [PATCH 1/7] Demonstration finite state machine for PEP 694 --- warehouse/forklift/state.py | 220 ++++++++++++++++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 warehouse/forklift/state.py diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py new file mode 100644 index 000000000000..fb9eb81fd619 --- /dev/null +++ b/warehouse/forklift/state.py @@ -0,0 +1,220 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +####################################################################################### +# This file demonstrates a Finite State Machine for the concepts of the File Upload +# Session and Upload Session defined in PEP 694. +####################################################################################### + +import dataclasses +import datetime + +from hashlib import sha256 +from typing import Any, Protocol + +import automat + + +@dataclasses.dataclass +class FileUploadMechanism: + name: str + requires_processing: bool + + +@dataclasses.dataclass +class FileUploadSession: + mechanism: FileUploadMechanism + + expiration: datetime.datetime + notices: list[str] + mechanism_details: dict[Any, Any] + + +class FileUploadSessionController(Protocol): + def action_ready(self) -> None: + "The File Upload Session was marked as ready" + + def action_cancel(self) -> None: + "The File Upload Session was marked as canceled" + + def action_extend(self, seconds: int) -> None: + "The File Upload Session was requested to be extended" + + def _process(self) -> None: + "The File Upload Session is processing a ready file upload" + + def _complete(self) -> None: + "The File Upload Session is complete" + + def _error(self, notice) -> None: + "The File Upload Session encountered an error" + + +def build_file_upload_session(): + + builder = automat.TypeMachineBuilder(FileUploadSessionController, FileUploadSession) + + pending = builder.state("pending") + processing = builder.state("processing") + complete = builder.state("complete") + error = builder.state("error") + canceled = builder.state("canceled") + + @pending.upon(FileUploadSessionController._process).to(processing) + def _process( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ) -> None: + pass + + @pending.upon(FileUploadSessionController._complete).to(complete) + def _complete( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ) -> None: + pass + + @pending.upon(FileUploadSessionController.action_cancel).to(canceled) + @processing.upon(FileUploadSessionController.action_cancel).to(canceled) + @complete.upon(FileUploadSessionController.action_cancel).to(canceled) + def action_cancel( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ) -> None: + pass + + @pending.upon(FileUploadSessionController._error).to(error) + @processing.upon(FileUploadSessionController._error).to(error) + def _error( + controller: FileUploadSessionController, + file_upload_session: FileUploadSession, + notice: str, + ) -> None: + file_upload_session.notices.append(notice) + + @pending.upon(FileUploadSessionController.action_ready).loop() + def action_ready( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ) -> None: + if file_upload_session.mechanism.requires_processing: + controller._process() + else: + controller._complete() + + @pending.upon(FileUploadSessionController.action_extend).loop() + def action_extend( + controller: FileUploadSessionController, + file_upload_session: FileUploadSession, + seconds: int, + ) -> None: + if file_upload_session.expiration >= datetime.datetime.now(datetime.UTC): + controller._error("Expired File Upload Sessions cannot be extended") + else: + file_upload_session.expiration = ( + file_upload_session.expiration + datetime.timedelta(seconds=seconds) + ) + + return builder.build() + + +FileUploadSessionFactory = build_file_upload_session() + + +@dataclasses.dataclass +class UploadSession: + project: str + version: str + file_upload_sessions: list[FileUploadSession] + + expiration: datetime.datetime + notices: list[str] + + nonce: str = "" + + @property + def can_publish(self): + return True + + @property + def session_token(self): + h = sha256() + h.update(self.name.encode()) + h.update(self.version.encode()) + h.update(self.nonce.encode()) + return h.hexdigest() + + +class UploadSessionController(Protocol): + def action_publish(self) -> None: + "The Upload Session was marked as published" + + def action_cancel(self) -> None: + "The Upload Session was marked as canceled" + + def action_extend(self, seconds: int) -> None: + "The Upload Session was requested to be extended" + + def _publish(self) -> None: + "The Upload Session was published" + + def _error(self, notice) -> None: + "The Upload Session encountered an error" + + +def build_upload_session(): + builder = automat.TypeMachineBuilder(UploadSessionController, UploadSession) + + pending = builder.state("pending") + published = builder.state("published") + error = builder.state("error") + canceled = builder.state("canceled") + + @pending.upon(UploadSessionController.action_publish).loop() + def action_publish( + controller: UploadSessionController, upload_session: UploadSession + ): + if upload_session.can_publish: + controller._publish() + else: + controller._error("Upload Session could not be published") + + @pending.upon(UploadSessionController.action_cancel).to(canceled) + @error.upon(UploadSessionController.action_cancel).to(canceled) + def action_cancel( + controller: UploadSessionController, upload_session: UploadSession + ): + pass + + @pending.upon(UploadSessionController._error).to(error) + def _error( + controller: UploadSessionController, upload_session: UploadSession, notice: str + ): + upload_session.notices.append(notice) + + @pending.upon(UploadSessionController._publish).to(published) + def _publish(controller: UploadSessionController, upload_session: UploadSession): + pass + + @pending.upon(UploadSessionController.action_extend).loop() + def action_extend( + controller: UploadSessionController, + upload_session: UploadSession, + seconds: int, + ) -> None: + if upload_session.expiration >= datetime.datetime.now(datetime.UTC): + controller._error("Expired Upload Sessions cannot be extended") + else: + upload_session.expiration = upload_session.expiration + datetime.timedelta( + seconds=seconds + ) + + return builder.build() + + +UploadSessionFactory = build_upload_session() From f47fbbdffb07af81e537d735b55bb78b8dbab10b Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 22 May 2025 11:55:18 -0400 Subject: [PATCH 2/7] further flesh out da state mucheens --- warehouse/forklift/state.py | 120 ++++++++++++++++++++++++++++++++---- 1 file changed, 109 insertions(+), 11 deletions(-) diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py index fb9eb81fd619..05780706eec6 100644 --- a/warehouse/forklift/state.py +++ b/warehouse/forklift/state.py @@ -17,6 +17,7 @@ import dataclasses import datetime +import uuid from hashlib import sha256 from typing import Any, Protocol @@ -24,19 +25,55 @@ import automat -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class FileUploadMechanism: name: str requires_processing: bool + def prepare(self, file_upload_session_id): + return {} + + +@dataclasses.dataclass(kw_only=True) +class HttpPostApplicationOctetFileUploadMechanism(FileUploadMechanism): + name: str = "http-post-application-octet-stream" + requires_processing: bool = False + + def prepare(self, file_upload_session_id): + return { + "upload-url": "http://example.com/upload/{file_upload_session_id}", + } + + +UPLOAD_MECHANISMS = { + "http-post-application-octet-stream": HttpPostApplicationOctetFileUploadMechanism() +} + @dataclasses.dataclass class FileUploadSession: + filename: str + size: int + hashes: dict[str, str] + metadata: str mechanism: FileUploadMechanism - expiration: datetime.datetime - notices: list[str] - mechanism_details: dict[Any, Any] + _upload_session_id = uuid.UUID + + expiration: datetime.datetime = dataclasses.field( + default_factory=lambda: datetime.datetime.now(datetime.UTC) + + datetime.timedelta(hours=1) + ) + notices: list[str] = dataclasses.field(default_factory=list) + mechanism_details: dict[Any, Any] = dataclasses.field(default_factory=dict) + _id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4) + + def prepare(self): + if self.mechanism: + if not self.mechanism_details: + self.mechanism_details = self.mechanism.prepare(self._id) + else: + raise RuntimeError("Mechanism not configured") class FileUploadSessionController(Protocol): @@ -60,7 +97,6 @@ def _error(self, notice) -> None: def build_file_upload_session(): - builder = automat.TypeMachineBuilder(FileUploadSessionController, FileUploadSession) pending = builder.state("pending") @@ -132,10 +168,39 @@ class UploadSession: version: str file_upload_sessions: list[FileUploadSession] - expiration: datetime.datetime notices: list[str] nonce: str = "" + expiration: datetime.datetime = dataclasses.field( + default_factory=lambda: datetime.datetime.now(datetime.UTC) + + datetime.timedelta(days=1) + ) + _token: str | None = None + _id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4) + + def create_file_upload_session( + self, + filename: str, + size: int, + hashes: dict[str, str], + metadata: str, + mechanism: str, + ): + _mechanism = UPLOAD_MECHANISMS.get(mechanism) + if _mechanism is None: + raise KeyError(f'No mechanism "{mechanism}" available.') + new_file_upload_session = FileUploadSessionFactory( + FileUploadSession( + filename=filename, + size=size, + hashes=hashes, + metadata=metadata, + mechanism=_mechanism, + ) + ) + new_file_upload_session.prepare() + self.file_upload_sessions.append(new_file_upload_session) + return new_file_upload_session @property def can_publish(self): @@ -143,14 +208,25 @@ def can_publish(self): @property def session_token(self): - h = sha256() - h.update(self.name.encode()) - h.update(self.version.encode()) - h.update(self.nonce.encode()) - return h.hexdigest() + if self._token is None: + h = sha256() + h.update(self.name.encode()) + h.update(self.version.encode()) + h.update(self.nonce.encode()) + self._token = h.hexdigest() + return self._token class UploadSessionController(Protocol): + def create_file_upload_session( + filename: str, + size: int, + hashes: dict[str, str], + metadata: str, + mechanism: str, + ) -> None: + "Create a new File Upload Session associated with this Upload Session" + def action_publish(self) -> None: "The Upload Session was marked as published" @@ -175,6 +251,28 @@ def build_upload_session(): error = builder.state("error") canceled = builder.state("canceled") + @pending.upon(UploadSessionController.create_file_upload_session).loop() + @error.upon(UploadSessionController.create_file_upload_session).loop() + def create_file_upload_session( + controller: UploadSessionController, + upload_session: UploadSession, + filename: str, + size: int, + hashes: dict[str, str], + metadata: str, + mechanism: str, + ): + try: + return upload_session.create_file_upload_session( + filename=filename, + size=size, + hashes=hashes, + metadata=metadata, + mechanism=mechanism, + ) + except KeyError as e: + controller._error(e) + @pending.upon(UploadSessionController.action_publish).loop() def action_publish( controller: UploadSessionController, upload_session: UploadSession From ed3536ad61da8cd4d82ad3ae4a472fa4d39a8f36 Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 22 May 2025 12:20:12 -0400 Subject: [PATCH 3/7] i think that rounds out the states and transitions... --- warehouse/forklift/state.py | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py index 05780706eec6..2306b12150d8 100644 --- a/warehouse/forklift/state.py +++ b/warehouse/forklift/state.py @@ -112,6 +112,7 @@ def _process( pass @pending.upon(FileUploadSessionController._complete).to(complete) + @processing.upon(FileUploadSessionController._complete).to(complete) def _complete( controller: FileUploadSessionController, file_upload_session: FileUploadSession ) -> None: @@ -120,6 +121,7 @@ def _complete( @pending.upon(FileUploadSessionController.action_cancel).to(canceled) @processing.upon(FileUploadSessionController.action_cancel).to(canceled) @complete.upon(FileUploadSessionController.action_cancel).to(canceled) + @error.upon(FileUploadSessionController.action_cancel).to(canceled) def action_cancel( controller: FileUploadSessionController, file_upload_session: FileUploadSession ) -> None: @@ -202,9 +204,13 @@ def create_file_upload_session( self.file_upload_sessions.append(new_file_upload_session) return new_file_upload_session + @property + def has_errors(self): + return len(self.notices) > 0 + @property def can_publish(self): - return True + return not self.has_errors @property def session_token(self): @@ -239,9 +245,15 @@ def action_extend(self, seconds: int) -> None: def _publish(self) -> None: "The Upload Session was published" + def _clear_errors(self) -> None: + "The Upload Session was revalidated" + def _error(self, notice) -> None: "The Upload Session encountered an error" + def _revalidate(self) -> None: + "The Upload Session should be revalidated" + def build_upload_session(): builder = automat.TypeMachineBuilder(UploadSessionController, UploadSession) @@ -289,6 +301,19 @@ def action_cancel( ): pass + @error.upon(UploadSessionController._clear_errors).to(pending) + def _clear_errors( + controller: UploadSessionController, upload_session: UploadSession, notice: str + ): + pass + + @error.upon(UploadSessionController._revalidate).loop() + def _revalidate( + controller: UploadSessionController, upload_session: UploadSession, notice: str + ): + if not upload_session.has_errors: + controller._clear_errors() + @pending.upon(UploadSessionController._error).to(error) def _error( controller: UploadSessionController, upload_session: UploadSession, notice: str From 2a313e0e9f953e19b38643b62dd04a27dec00f3b Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 22 May 2025 13:53:31 -0400 Subject: [PATCH 4/7] start serializing and exercising --- warehouse/forklift/state.py | 133 ++++++++++++++++++++++++++++++++++-- 1 file changed, 127 insertions(+), 6 deletions(-) diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py index 2306b12150d8..4785a2615f95 100644 --- a/warehouse/forklift/state.py +++ b/warehouse/forklift/state.py @@ -41,7 +41,7 @@ class HttpPostApplicationOctetFileUploadMechanism(FileUploadMechanism): def prepare(self, file_upload_session_id): return { - "upload-url": "http://example.com/upload/{file_upload_session_id}", + "upload-url": f"http://example.com/upload/{file_upload_session_id}", } @@ -50,7 +50,7 @@ def prepare(self, file_upload_session_id): } -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class FileUploadSession: filename: str size: int @@ -68,6 +68,14 @@ class FileUploadSession: mechanism_details: dict[Any, Any] = dataclasses.field(default_factory=dict) _id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4) + def serialize(self) -> dict[str, Any]: + return { + "valid-for": max( + 0, (self.expiration - datetime.datetime.now(datetime.UTC)).seconds + ), + "mechanism": {self.mechanism.name: self.mechanism_details}, + } + def prepare(self): if self.mechanism: if not self.mechanism_details: @@ -77,6 +85,8 @@ def prepare(self): class FileUploadSessionController(Protocol): + def serialize(self) -> dict[str, Any]: ... + def action_ready(self) -> None: "The File Upload Session was marked as ready" @@ -86,6 +96,9 @@ def action_cancel(self) -> None: def action_extend(self, seconds: int) -> None: "The File Upload Session was requested to be extended" + def prepare(self) -> None: + "Prepare the File Upload Session for upload" + def _process(self) -> None: "The File Upload Session is processing a ready file upload" @@ -105,6 +118,57 @@ def build_file_upload_session(): error = builder.state("error") canceled = builder.state("canceled") + @pending.upon(FileUploadSessionController.serialize).loop() + def serialize_pending( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ): + return ( + file_upload_session.filename, + file_upload_session.serialize() | {"status": "pending"}, + ) + + @processing.upon(FileUploadSessionController.serialize).loop() + def serialize_processing( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ): + return ( + file_upload_session.filename, + file_upload_session.serialize() | {"status": "processing"}, + ) + + @complete.upon(FileUploadSessionController.serialize).loop() + def serialize_complete( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ): + return ( + file_upload_session.filename, + file_upload_session.serialize() | {"status": "complete"}, + ) + + @error.upon(FileUploadSessionController.serialize).loop() + def serialize_error( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ): + return ( + file_upload_session.filename, + file_upload_session.serialize() | {"status": "error"}, + ) + + @canceled.upon(FileUploadSessionController.serialize).loop() + def serialize_canceled( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ): + return ( + file_upload_session.filename, + file_upload_session.serialize() | {"status": "canceled"}, + ) + + @pending.upon(FileUploadSessionController.prepare).loop() + def prepare( + controller: FileUploadSessionController, file_upload_session: FileUploadSession + ) -> None: + file_upload_session.prepare() + @pending.upon(FileUploadSessionController._process).to(processing) def _process( controller: FileUploadSessionController, file_upload_session: FileUploadSession @@ -164,15 +228,16 @@ def action_extend( FileUploadSessionFactory = build_file_upload_session() -@dataclasses.dataclass +@dataclasses.dataclass(kw_only=True) class UploadSession: project: str version: str - file_upload_sessions: list[FileUploadSession] - - notices: list[str] nonce: str = "" + file_upload_sessions: list[FileUploadSession] = dataclasses.field( + default_factory=list + ) + notices: list[str] = dataclasses.field(default_factory=list) expiration: datetime.datetime = dataclasses.field( default_factory=lambda: datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=1) @@ -180,6 +245,22 @@ class UploadSession: _token: str | None = None _id: uuid.UUID = dataclasses.field(default_factory=uuid.uuid4) + def serialize(self): + return { + "mechanisms": [UPLOAD_MECHANISMS.keys()], + "session-token": self._token, + "valid-for": max( + 0, (self.expiration - datetime.datetime.now(datetime.UTC)).seconds + ), + "files": dict( + [ + file_upload_session.serialize() + for file_upload_session in self.file_upload_sessions + ] + ), + "notices": self.notices, + } + def create_file_upload_session( self, filename: str, @@ -254,6 +335,8 @@ def _error(self, notice) -> None: def _revalidate(self) -> None: "The Upload Session should be revalidated" + def serialize(self) -> dict[str, Any]: ... + def build_upload_session(): builder = automat.TypeMachineBuilder(UploadSessionController, UploadSession) @@ -263,6 +346,30 @@ def build_upload_session(): error = builder.state("error") canceled = builder.state("canceled") + @pending.upon(UploadSessionController.serialize).loop() + def serialize_pending( + controller: UploadSessionController, upload_session: UploadSession + ): + return upload_session.serialize() | {"status": "pending"} + + @published.upon(UploadSessionController.serialize).loop() + def serialize_published( + controller: UploadSessionController, upload_session: UploadSession + ): + return upload_session.serialize() | {"status": "published"} + + @error.upon(UploadSessionController.serialize).loop() + def serialize_error( + controller: UploadSessionController, upload_session: UploadSession + ): + return upload_session.serialize() | {"status": "error"} + + @canceled.upon(UploadSessionController.serialize).loop() + def serialize_canceled( + controller: UploadSessionController, upload_session: UploadSession + ): + return upload_session.serialize() | {"status": "canceled"} + @pending.upon(UploadSessionController.create_file_upload_session).loop() @error.upon(UploadSessionController.create_file_upload_session).loop() def create_file_upload_session( @@ -341,3 +448,17 @@ def action_extend( UploadSessionFactory = build_upload_session() + +if __name__ == "__main__": + from pprint import pprint + + upload_session = UploadSessionFactory( + UploadSession(project="wutang", version="6.6.69") + ) + pprint(upload_session.serialize()) + file_upload_session = upload_session.create_file_upload_session( + "wutang-6.6.69.tar.gz", 420, {}, "", "http-post-application-octet-stream" + ) + pprint(upload_session.serialize()) + file_upload_session.action_ready() + pprint(upload_session.serialize()) From 3f4baf76b5deb0768c0c69fdbd3da5ed373e9f8e Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 22 May 2025 13:57:26 -0400 Subject: [PATCH 5/7] lint --- warehouse/forklift/state.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py index 4785a2615f95..99cd3214e13c 100644 --- a/warehouse/forklift/state.py +++ b/warehouse/forklift/state.py @@ -85,7 +85,8 @@ def prepare(self): class FileUploadSessionController(Protocol): - def serialize(self) -> dict[str, Any]: ... + def serialize(self) -> dict[str, Any]: + "Serialize the machine" def action_ready(self) -> None: "The File Upload Session was marked as ready" @@ -306,6 +307,7 @@ def session_token(self): class UploadSessionController(Protocol): def create_file_upload_session( + self, filename: str, size: int, hashes: dict[str, str], @@ -335,7 +337,8 @@ def _error(self, notice) -> None: def _revalidate(self) -> None: "The Upload Session should be revalidated" - def serialize(self) -> dict[str, Any]: ... + def serialize(self) -> dict[str, Any]: + "Serialize the machine" def build_upload_session(): From 83dccf88f8f55cbdd269b6a1b41e95d1758f364b Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Thu, 22 May 2025 14:00:05 -0400 Subject: [PATCH 6/7] fix session token --- warehouse/forklift/state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py index 99cd3214e13c..a5daee216bdc 100644 --- a/warehouse/forklift/state.py +++ b/warehouse/forklift/state.py @@ -249,7 +249,7 @@ class UploadSession: def serialize(self): return { "mechanisms": [UPLOAD_MECHANISMS.keys()], - "session-token": self._token, + "session-token": self.session_token, "valid-for": max( 0, (self.expiration - datetime.datetime.now(datetime.UTC)).seconds ), @@ -298,7 +298,7 @@ def can_publish(self): def session_token(self): if self._token is None: h = sha256() - h.update(self.name.encode()) + h.update(self.project.encode()) h.update(self.version.encode()) h.update(self.nonce.encode()) self._token = h.hexdigest() From 18962dfafb7e2e17a4cdd284d10641e0c46f432e Mon Sep 17 00:00:00 2001 From: Ee Durbin Date: Tue, 3 Jun 2025 04:32:51 -0400 Subject: [PATCH 7/7] fix some syntax, ensure jsonable --- warehouse/forklift/state.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/warehouse/forklift/state.py b/warehouse/forklift/state.py index a5daee216bdc..e86712df0125 100644 --- a/warehouse/forklift/state.py +++ b/warehouse/forklift/state.py @@ -248,7 +248,7 @@ class UploadSession: def serialize(self): return { - "mechanisms": [UPLOAD_MECHANISMS.keys()], + "mechanisms": list(UPLOAD_MECHANISMS.keys()), "session-token": self.session_token, "valid-for": max( 0, (self.expiration - datetime.datetime.now(datetime.UTC)).seconds @@ -292,7 +292,12 @@ def has_errors(self): @property def can_publish(self): - return not self.has_errors + return not any( + [ + upload_session.serialize()["state"] in ["error", "pending"] + for upload_session in self.file_upload_sessions + ] + ) @property def session_token(self): @@ -453,15 +458,18 @@ def action_extend( UploadSessionFactory = build_upload_session() if __name__ == "__main__": - from pprint import pprint + import json upload_session = UploadSessionFactory( UploadSession(project="wutang", version="6.6.69") ) - pprint(upload_session.serialize()) + print(upload_session.can_publish) + print(json.dumps(upload_session.serialize())) file_upload_session = upload_session.create_file_upload_session( "wutang-6.6.69.tar.gz", 420, {}, "", "http-post-application-octet-stream" ) - pprint(upload_session.serialize()) + print(upload_session.can_publish) + print(json.dumps(upload_session.serialize())) file_upload_session.action_ready() - pprint(upload_session.serialize()) + print(upload_session.can_publish) + print(json.dumps(upload_session.serialize()))