Skip to content

Commit bdbc776

Browse files
monkey patch python 3.13 shutdown
1 parent f460ae8 commit bdbc776

8 files changed

Lines changed: 130 additions & 38 deletions

File tree

‎.github/workflows/python-package.yml‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
name: Build, Test, and Publish Wheel
22

3-
on: [push, pull_request]
3+
on:
4+
push:
5+
branches:
6+
- "**"
7+
tags:
8+
- "v*"
9+
pull_request:
410

511
jobs:
612
build:
@@ -165,7 +171,8 @@ jobs:
165171
publish:
166172
needs: [test-linux, test-windows, test-macos]
167173
runs-on: ubuntu-latest
168-
if: "${{ contains(github.event.head_commit.message, 'PUBLISH_NOW') }}"
174+
# Publish only when a version tag (v*) is pushed
175+
if: startsWith(github.ref, 'refs/tags/v')
169176

170177
steps:
171178
- uses: actions/checkout@v4
@@ -198,4 +205,4 @@ jobs:
198205
env:
199206
TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }}
200207
TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }}
201-
run: twine upload dist/*
208+
run: twine upload dist/*

‎.gitignore‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@
22
*.pyc
33
build
44
dist
5+
.idea
56
lib3mf.egg-info

‎README.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,5 +34,6 @@ This script automatically detects the platform and builds the appropriate wheel.
3434
## Automatic python wheel building and publishing
3535

3636
Every commit automatically builds and tests the python wheels for all 3 platforms.
37-
However, to publish them to PyPI, the commit message should contain the string `PUBLISH_NOW`
38-
Without this string, deployment to PyPI will be skipped.
37+
Publishing to PyPI now happens only when a version tag is pushed (e.g. `v1.2.3`).
38+
Push a tag after merging to trigger the publish step; regular commits and PRs continue
39+
to build and test without deploying.

‎lib3mf/__init__.py‎

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
# Determine the current operating system
99
system = platform.system().lower()
1010

11-
1211
# Determine the platform and choose the right library extension
1312
if system == "linux":
1413
lib_file = "lib3mf.so"
@@ -23,40 +22,80 @@
2322
dir_path = os.path.dirname(os.path.realpath(__file__))
2423
lib_path = os.path.join(dir_path, lib_file)
2524

26-
2725
if not os.path.exists(lib_path):
2826
raise ImportError(f"The required binary {lib_path} could not be found in {dir_path}")
2927

3028
# Add the path to the system path if necessary (useful for DLLs on Windows)
3129
sys.path.append(dir_path)
3230

33-
# Ensure that your Lib3MF.py wraps around the shared library correctly
34-
from .Lib3MF import * # Import necessary classes/functions from your Lib3MF.py
31+
# Import generated bindings
32+
from .Lib3MF import *
33+
34+
35+
# ---------------------------
36+
# Public helpers
37+
# ---------------------------
3538

36-
# Returns the raw library path
3739
def get_library_path():
3840
global lib_path
3941
return lib_path
4042

41-
# For existing codes that use Wrapper class directly
43+
4244
def get_library_path_for_wrapper():
4345
global lib_path
44-
if system == "linux":
45-
if str(lib_path).endswith(".so"):
46-
return lib_path[:-3]
47-
if system == "windows":
48-
if str(lib_path).endswith(".dll"):
49-
return lib_path[:-4]
50-
if system == "darwin":
51-
if str(lib_path).endswith(".dylib"):
52-
return lib_path[:-6]
53-
54-
# Your existing logic to use the library
46+
path_str = str(lib_path)
47+
48+
if system == "linux" and path_str.endswith(".so"):
49+
return path_str[:-3]
50+
if system == "windows" and path_str.endswith(".dll"):
51+
return path_str[:-4]
52+
if system == "darwin" and path_str.endswith(".dylib"):
53+
return path_str[:-6]
54+
55+
base, _ext = os.path.splitext(path_str)
56+
return base
57+
58+
5559
def get_wrapper():
56-
global lib_path
60+
from . import Lib3MF as _Lib3MF
5761
try:
58-
# No need to modify lib_path here; just use it directly in your wrapper
59-
return Wrapper(get_library_path_for_wrapper())
60-
except ELib3MFException as e:
62+
return _Lib3MF.Wrapper(get_library_path_for_wrapper())
63+
except _Lib3MF.ELib3MFException as e:
6164
print("Failed to initialize the Lib3MF wrapper: ", e)
6265
raise
66+
67+
68+
# ---------------------------------------------------------------------------
69+
# PYTHON 3.13 SHUTDOWN SAFETY MONKEY PATCH
70+
#
71+
# Fixes:
72+
# Exception ignored in: Base.__del__
73+
# AttributeError: 'NoneType' object has no attribute 'SUCCESS'
74+
#
75+
# Root cause:
76+
# During interpreter shutdown, Lib3MF.ErrorCodes becomes None.
77+
# Base.__del__ -> Wrapper.Release -> checkError -> ErrorCodes.SUCCESS
78+
#
79+
# Solution:
80+
# Patch Wrapper.checkError to NO-OP if ErrorCodes is already None.
81+
#
82+
# This preserves full runtime behavior and only affects shutdown.
83+
# ---------------------------------------------------------------------------
84+
85+
try:
86+
from . import Lib3MF as _Lib3MF
87+
88+
_original_checkError = _Lib3MF.Wrapper.checkError
89+
90+
def _patched_checkError(self, instance, errorCode):
91+
ec = getattr(_Lib3MF, "ErrorCodes", None)
92+
if ec is None:
93+
# Interpreter shutdown: suppress destructor noise
94+
return
95+
return _original_checkError(self, instance, errorCode)
96+
97+
_Lib3MF.Wrapper.checkError = _patched_checkError
98+
99+
except Exception:
100+
# Never block import if bindings differ slightly
101+
pass

‎prepare_pypi_release.py‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,22 @@
11
import os
22
import sys
3-
import requests
4-
import zipfile
53
import io
64
import shutil
75
import subprocess
6+
import zipfile
7+
8+
import requests
89

910
# Download SDK directly to zip object
1011
def download_sdk_zip(url):
11-
response = requests.get(url)
12+
response = requests.get(url, timeout=30)
1213
if response.status_code == 200:
1314
return zipfile.ZipFile(io.BytesIO(response.content))
1415
else:
1516
raise Exception(f"Failed to download SDK zip from {url}")
1617

1718
def download_file(url):
18-
response = requests.get(url)
19+
response = requests.get(url, timeout=30)
1920
if response.status_code == 200:
2021
return response.text
2122
else:
@@ -26,10 +27,16 @@ def clean_lib3mf_directory(lib3mf_dir):
2627
valid_extensions = {'.so', '.dylib', '.dll', 'Lib3MF.py', '__init__.py'}
2728
before_path = os.getcwd()
2829
os.chdir(lib3mf_dir)
29-
for item in os.listdir():
30-
if not any(item.endswith(ext) for ext in valid_extensions):
31-
os.unlink(item)
32-
os.chdir(before_path)
30+
try:
31+
for item in os.listdir():
32+
if any(item.endswith(ext) for ext in valid_extensions):
33+
continue
34+
if os.path.isdir(item):
35+
shutil.rmtree(item)
36+
else:
37+
os.unlink(item)
38+
finally:
39+
os.chdir(before_path)
3340
print("Remove unnecessary files and cleaned up the lib3mf directory")
3441

3542

@@ -69,12 +76,17 @@ def update_readme(version):
6976
readme_content = download_file(readme_url)
7077

7178
# Read the example script content
72-
with open("create_cube_example_complete.py", "r", encoding="utf-8") as file:
79+
with open("examples/create_cube_example_complete.py", "r", encoding="utf-8") as file:
7380
create_cube_example_complete = file.read()
7481

7582
# Insert the example before the documentation section
76-
readme_parts = readme_content.split("## Documentation")
77-
readme_content = readme_parts[0] + "\n\n## Example (Create Cube)\n\n```python\n" + create_cube_example_complete + "\n```\n\n## Documentation" + readme_parts[1]
83+
marker = "## Documentation"
84+
if marker in readme_content:
85+
readme_parts = readme_content.split(marker, 1)
86+
readme_content = readme_parts[0] + "\n\n## Example (Create Cube)\n\n```python\n" + create_cube_example_complete + "\n```\n\n" + marker + readme_parts[1]
87+
else:
88+
# Fallback: append the example to the end
89+
readme_content = readme_content + "\n\n## Example (Create Cube)\n\n```python\n" + create_cube_example_complete + "\n```\n"
7890

7991
with open(readme_file, "w", encoding="utf-8") as file:
8092
file.write(readme_content)

‎setup.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def find_real_lib_path():
4343
# Setup script
4444
setup(
4545
name='lib3mf',
46-
version='2.4.1',
46+
version='2.4.1.post1',
4747
description='lib3mf is an implementation of the 3D Manufacturing Format file standard',
4848
long_description=readme_content,
4949
long_description_content_type='text/markdown',

‎tests/lib3mf_py313_exception.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import atexit
2+
import lib3mf
3+
from lib3mf import Lib3MF as _L # this is the generated Lib3MF.py module
4+
5+
6+
print("Using lib3mf from:", lib3mf.__file__)
7+
print("Using Lib3MF module from:", _L.__file__)
8+
9+
# --- Create some lib3mf objects as GLOBALS (so they live until shutdown) ---
10+
11+
wrapper = lib3mf.get_wrapper()
12+
model = wrapper.CreateModel()
13+
mesh = model.AddMeshObject()
14+
mesh.SetName("py313-shutdown-test")
15+
writer = model.QueryWriter("3mf")
16+
17+
18+
def simulate_shutdown_bug():
19+
print("Simulating interpreter teardown: setting Lib3MF.ErrorCodes = None")
20+
# This mimics what CPython does when tearing down modules:
21+
_L.ErrorCodes = None
22+
23+
print("Deleting mesh -> Base.__del__ -> Wrapper.Release -> checkError(ErrorCodes=None)")
24+
# Deleting the mesh will trigger Base.__del__, which calls wrapper.Release(self),
25+
# which calls checkError and tries to use ErrorCodes.SUCCESS -> boom.
26+
global mesh
27+
del mesh
28+
29+
30+
atexit.register(simulate_shutdown_bug)
31+
32+
print("Setup complete. Exiting will trigger simulated shutdown.\n")

0 commit comments

Comments
 (0)