aird 0.4.23.dev0__tar.gz → 0.4.23.dev3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/PKG-INFO +5 -1
  2. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/README.md +4 -0
  3. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/config.py +11 -1
  4. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/constants/__init__.py +28 -3
  5. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/shares.py +17 -5
  6. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/api_handlers.py +27 -8
  7. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/base_handler.py +18 -5
  8. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/file_op_handlers.py +12 -1
  9. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/share_handlers.py +50 -27
  10. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/main.py +77 -48
  11. aird-0.4.23.dev3/aird/server_runtime.py +87 -0
  12. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/css/app.css +1 -1
  13. aird-0.4.23.dev3/aird/static/favicon.png +0 -0
  14. aird-0.4.23.dev3/aird/static/favicon.svg +12 -0
  15. aird-0.4.23.dev3/aird/static/img/logo-icon.png +0 -0
  16. aird-0.4.23.dev3/aird/static/img/logo-mark.svg +15 -0
  17. aird-0.4.23.dev3/aird/static/img/logo-text.png +0 -0
  18. aird-0.4.23.dev3/aird/static/img/logo.png +0 -0
  19. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/browse/app.js +146 -23
  20. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/share/app.js +32 -9
  21. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/_theme_early.html +1 -1
  22. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin.html +1 -1
  23. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_audit.html +1 -1
  24. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_ldap.html +1 -1
  25. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_login.html +1 -1
  26. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_network_shares.html +1 -1
  27. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_policies.html +1 -1
  28. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_tags.html +1 -1
  29. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_user_attributes.html +1 -1
  30. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/admin_users.html +1 -1
  31. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/browse.html +2 -2
  32. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/directory.html +1 -1
  33. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/edit.html +1 -1
  34. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/error.html +1 -1
  35. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/file.html +1 -1
  36. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/ldap_config_create.html +1 -1
  37. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/ldap_config_edit.html +1 -1
  38. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/login.html +1 -1
  39. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/p2p_transfer.html +1 -1
  40. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/profile.html +1 -1
  41. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/share.html +1 -1
  42. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/shared_list.html +3 -8
  43. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/super_search.html +1 -1
  44. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/tagged_files.html +1 -1
  45. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/token_verification.html +1 -1
  46. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/user_create.html +1 -1
  47. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/user_edit.html +1 -1
  48. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird.egg-info/PKG-INFO +5 -1
  49. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird.egg-info/SOURCES.txt +9 -0
  50. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/setup.py +4 -1
  51. aird-0.4.23.dev3/tests/test_server_runtime.py +33 -0
  52. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_share_handlers.py +33 -1
  53. aird-0.4.23.dev3/tests/test_share_ownership.py +91 -0
  54. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/LICENSE +0 -0
  55. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/__init__.py +0 -0
  56. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/__main__.py +0 -0
  57. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/app_context.py +0 -0
  58. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/cloud/__init__.py +0 -0
  59. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/constants/admin.py +0 -0
  60. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/constants/file_ops.py +0 -0
  61. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/constants/input_limits.py +0 -0
  62. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/constants/media.py +0 -0
  63. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/__init__.py +0 -0
  64. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/events.py +0 -0
  65. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/file_operations.py +0 -0
  66. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/filter_expression.py +0 -0
  67. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/input_validation.py +0 -0
  68. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/mmap_handler.py +0 -0
  69. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/security.py +0 -0
  70. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/share_root.py +0 -0
  71. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/core/websocket_manager.py +0 -0
  72. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/database/__init__.py +0 -0
  73. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/database/db.py +0 -0
  74. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/database/feature_flags.py +0 -0
  75. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/database/ldap.py +0 -0
  76. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/__init__.py +0 -0
  77. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/audit.py +0 -0
  78. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/config.py +0 -0
  79. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/favorites.py +0 -0
  80. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/network_shares.py +0 -0
  81. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/policies.py +0 -0
  82. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/policy_decisions.py +0 -0
  83. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/policy_seeds.py +0 -0
  84. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/quota.py +0 -0
  85. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/resource_tags.py +0 -0
  86. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/schema.py +0 -0
  87. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/user_attributes.py +0 -0
  88. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/db/users.py +0 -0
  89. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/domain/__init__.py +0 -0
  90. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/domain/contracts.py +0 -0
  91. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/domain/models.py +0 -0
  92. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/__init__.py +0 -0
  93. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/abac_handlers.py +0 -0
  94. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/admin_handlers.py +0 -0
  95. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/auth_handlers.py +0 -0
  96. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/constants.py +0 -0
  97. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/health_handler.py +0 -0
  98. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/p2p_handlers.py +0 -0
  99. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/handlers/view_handlers.py +0 -0
  100. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/network_share_manager.py +0 -0
  101. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/__init__.py +0 -0
  102. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/audit_service.py +0 -0
  103. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/config_service.py +0 -0
  104. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/event_subscribers.py +0 -0
  105. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/favorites_service.py +0 -0
  106. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/network_share_service.py +0 -0
  107. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/p2p_service.py +0 -0
  108. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/policy_service.py +0 -0
  109. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/quota_service.py +0 -0
  110. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/share_service.py +0 -0
  111. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/tag_service.py +0 -0
  112. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/services/user_service.py +0 -0
  113. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/sql_identifiers.py +0 -0
  114. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/aird-core.js +0 -0
  115. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/bg-canvas.js +0 -0
  116. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/common/command-palette.js +0 -0
  117. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/components/folder-picker.js +0 -0
  118. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/feature-flags-live.js +0 -0
  119. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/login-ui.js +0 -0
  120. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/p2p/app.js +0 -0
  121. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/p2p/mediator.js +0 -0
  122. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/p2p/qr-adapter.js +0 -0
  123. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/p2p/signaling-service.js +0 -0
  124. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/p2p/state-machine.js +0 -0
  125. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/p2p/transfer-service.js +0 -0
  126. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/pages/p2p-page.js +0 -0
  127. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/pages/super-search.js +0 -0
  128. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/theme.js +0 -0
  129. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/vendor/pdf.min.js +0 -0
  130. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/vendor/pdf.worker.min.js +0 -0
  131. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/static/js/vendor/qrcode-browser.js +0 -0
  132. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/_admin_tabs.html +0 -0
  133. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/_app_nav_header.html +0 -0
  134. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/_bg_canvas.html +0 -0
  135. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/templates/_theme_login_corner.html +0 -0
  136. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/utils/__init__.py +0 -0
  137. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird/utils/util.py +0 -0
  138. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird.egg-info/dependency_links.txt +0 -0
  139. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird.egg-info/entry_points.txt +0 -0
  140. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird.egg-info/requires.txt +0 -0
  141. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/aird.egg-info/top_level.txt +0 -0
  142. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/setup.cfg +0 -0
  143. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/__init__.py +0 -0
  144. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/conftest.py +0 -0
  145. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/handler_helpers.py +0 -0
  146. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_admin_handlers.py +0 -0
  147. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_api_handlers.py +0 -0
  148. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_architecture_conformance.py +0 -0
  149. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_auth_handlers.py +0 -0
  150. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_auth_handlers_extended.py +0 -0
  151. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_base_handler.py +0 -0
  152. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_base_handler_pep.py +0 -0
  153. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_cloud.py +0 -0
  154. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_config.py +0 -0
  155. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_core_file_operations.py +0 -0
  156. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_database_db.py +0 -0
  157. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_database_feature_flags.py +0 -0
  158. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_database_ldap.py +0 -0
  159. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_database_shares.py +0 -0
  160. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_database_users.py +0 -0
  161. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_database_users_hashing.py +0 -0
  162. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_db.py +0 -0
  163. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_file_op_handlers.py +0 -0
  164. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_filter_expression.py +0 -0
  165. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_main.py +0 -0
  166. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_mmap_handler.py +0 -0
  167. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_multi_user.py +0 -0
  168. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_network_shares.py +0 -0
  169. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_p2p_handlers.py +0 -0
  170. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_password_hashing.py +0 -0
  171. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_policy_service.py +0 -0
  172. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_rate_limit.py +0 -0
  173. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_security.py +0 -0
  174. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_security_comprehensive.py +0 -0
  175. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_super_search_handler.py +0 -0
  176. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_tag_service.py +0 -0
  177. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_util.py +0 -0
  178. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_view_handlers.py +0 -0
  179. {aird-0.4.23.dev0 → aird-0.4.23.dev3}/tests/test_websocket_manager.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aird
3
- Version: 0.4.23.dev0
3
+ Version: 0.4.23.dev3
4
4
  Summary: Aird - A lightweight web-based file browser, editor, and streamer with real-time capabilities
5
5
  Home-page: https://github.com/blinkerbit/aird
6
6
  Author: Viswantha Srinivas P
@@ -36,6 +36,10 @@ Dynamic: summary
36
36
 
37
37
  # Aird - Modern Web-Based File Management Platform
38
38
 
39
+ <p align="center">
40
+ <img src="aird/static/img/logo.png" alt="Aird" width="280">
41
+ </p>
42
+
39
43
  ![Aird Demo Video](./demo.webp)
40
44
 
41
45
  🚀 **A lightweight, fast, and secure web-based file browser, editor, and sharing platform built with Python and Tornado.**
@@ -1,5 +1,9 @@
1
1
  # Aird - Modern Web-Based File Management Platform
2
2
 
3
+ <p align="center">
4
+ <img src="aird/static/img/logo.png" alt="Aird" width="280">
5
+ </p>
6
+
3
7
  ![Aird Demo Video](./demo.webp)
4
8
 
5
9
  🚀 **A lightweight, fast, and secure web-based file browser, editor, and sharing platform built with Python and Tornado.**
@@ -42,6 +42,7 @@ FEATURE_FLAGS = {}
42
42
  CLOUD_MANAGER = CloudManager()
43
43
  WEBSOCKET_CONFIG = {}
44
44
  MULTI_USER = False
45
+ WORKERS = None # None = auto (1.25 * threads_per_core * physical_cores)
45
46
  DB_CONN = None
46
47
  MAX_FILE_SIZE = _MAX_FILE_SIZE
47
48
  MAX_READABLE_FILE_SIZE = _MAX_READABLE_FILE_SIZE
@@ -170,7 +171,7 @@ def init_config():
170
171
  global CONFIG_FILE, ROOT_DIR, PORT, ACCESS_TOKEN, ADMIN_TOKEN, LDAP_ENABLED, LDAP_SERVER
171
172
  global LDAP_BASE_DN, LDAP_USER_TEMPLATE, LDAP_FILTER_TEMPLATE, LDAP_ATTRIBUTES
172
173
  global LDAP_ATTRIBUTE_MAP, HOSTNAME, SSL_CERT, SSL_KEY, ADMIN_USERS, FEATURE_FLAGS, CLOUD_MANAGER
173
- global MULTI_USER
174
+ global MULTI_USER, WORKERS
174
175
 
175
176
  parser = argparse.ArgumentParser(description="Run Aird")
176
177
  parser.add_argument("--config", help="Path to JSON config file")
@@ -202,6 +203,12 @@ def init_config():
202
203
  action="store_true",
203
204
  help="Enable multi-user mode (each user gets a private home directory)",
204
205
  )
206
+ parser.add_argument(
207
+ "--workers",
208
+ type=int,
209
+ default=None,
210
+ help="HTTP worker processes (default: ceil(1.25 * threads_per_core * physical_cores); 1 on Windows)",
211
+ )
205
212
  args = parser.parse_args()
206
213
 
207
214
  config = {}
@@ -247,6 +254,9 @@ def init_config():
247
254
 
248
255
  MULTI_USER = args.multi_user or config.get("multi_user", False)
249
256
 
257
+ workers_arg = args.workers if args.workers is not None else config.get("workers")
258
+ WORKERS = int(workers_arg) if workers_arg is not None else None
259
+
250
260
  SSL_CERT = args.ssl_cert or config.get("ssl_cert")
251
261
  SSL_KEY = args.ssl_key or config.get("ssl_key")
252
262
 
@@ -59,10 +59,10 @@ UPLOAD_CONFIG = {
59
59
  "allow_all_file_types": 0, # 0 = use whitelist below, 1 = allow any extension
60
60
  }
61
61
 
62
- # Per-request body limit (Cloudflare-compatible chunked uploads; admin sets total file cap separately)
63
- UPLOAD_CHUNK_SIZE_BYTES = 90 * 1024 * 1024 # 90 MiB per HTTP request
62
+ # Per-request body limit (Cloudflare: keep each POST under ~100 MB and ~100s proxy timeout)
63
+ UPLOAD_CHUNK_SIZE_BYTES = 50 * 1024 * 1024 # 50 MiB per HTTP request
64
64
  UPLOAD_REQUEST_MAX_BODY_SIZE = UPLOAD_CHUNK_SIZE_BYTES + (1024 * 1024) # chunk + slack
65
- UPLOAD_MAX_PARALLEL_CHUNKS = 5 # max concurrent chunk requests from the browser
65
+ UPLOAD_MAX_PARALLEL_CHUNKS = 3 # fewer concurrent POSTs through reverse proxies
66
66
 
67
67
  # File operation constants (derived from UPLOAD_CONFIG at startup)
68
68
  MAX_FILE_SIZE = UPLOAD_CONFIG["max_file_size_mb"] * 1024 * 1024
@@ -118,3 +118,28 @@ CORPORATE_IP_CIDRS: list[str] = [
118
118
  for c in os.environ.get("AIRD_CORPORATE_IP_CIDRS", "").split(",")
119
119
  if c.strip()
120
120
  ]
121
+
122
+
123
+ def _read_app_version() -> str:
124
+ """Package version for cache-busting static assets in templates (?v=…)."""
125
+ try:
126
+ from importlib.metadata import version
127
+
128
+ return version("aird")
129
+ except Exception:
130
+ pass
131
+ try:
132
+ import re
133
+ from pathlib import Path
134
+
135
+ setup_py = Path(__file__).resolve().parents[2] / "setup.py"
136
+ text = setup_py.read_text(encoding="utf-8")
137
+ match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', text)
138
+ if match:
139
+ return match.group(1)
140
+ except Exception:
141
+ pass
142
+ return "dev"
143
+
144
+
145
+ APP_VERSION = _read_app_version()
@@ -316,9 +316,14 @@ def list_shares_accessible_to_user(conn: sqlite3.Connection, username: str) -> l
316
316
  result = []
317
317
  for row in cursor:
318
318
  share = _row_to_share_dict(row, col_names)
319
+ if login_matches_share_creator_field(share.get("created_by"), username):
320
+ continue
319
321
  allowed = share.get("allowed_users")
320
- is_creator = login_matches_share_creator_field(share.get("created_by"), username)
321
- if allowed is None or username in allowed or is_creator:
322
+ modify_users = share.get("modify_users") or []
323
+ if username in modify_users:
324
+ result.append(share)
325
+ continue
326
+ if allowed is None or username in allowed:
322
327
  result.append(share)
323
328
  return result
324
329
  except Exception as e:
@@ -375,9 +380,16 @@ def _share_covers_dynamic_path(share: dict, rel_path: str, root_dir: str) -> boo
375
380
  try:
376
381
  full_folder_path = os.path.abspath(os.path.join(root_dir, folder_path))
377
382
  full_file_path = os.path.abspath(os.path.join(root_dir, rel_path))
378
- if (
379
- os.path.isdir(full_folder_path)
380
- and is_within_root(full_file_path, full_folder_path)
383
+ if not is_within_root(full_file_path, root_dir):
384
+ continue
385
+ if os.path.isdir(full_folder_path) and is_within_root(
386
+ full_file_path, full_folder_path
387
+ ):
388
+ if filter_files_by_patterns([rel_path], allow_list, avoid_list):
389
+ return True
390
+ elif (
391
+ os.path.isfile(full_folder_path)
392
+ and folder_path.replace("\\", "/") == rel_path.replace("\\", "/")
381
393
  and filter_files_by_patterns([rel_path], allow_list, avoid_list)
382
394
  ):
383
395
  return True
@@ -927,12 +927,14 @@ class ShareDetailsByIdAPIHandler(BaseHandler):
927
927
  self.write_json_error(404, "Share not found")
928
928
  return None
929
929
 
930
- if not self.can_manage_share_secrets(share):
930
+ if not self.can_edit_share_paths(share):
931
931
  self.write_json_error(403, "Access denied")
932
932
  return None
933
933
 
934
934
  allowed_users = share.get("allowed_users")
935
935
  modify_users = share.get("modify_users")
936
+ show_secret = self.can_manage_share_secrets(share)
937
+ token = share.get("secret_token")
936
938
  share_info = {
937
939
  "id": share["id"],
938
940
  "created": share.get("created", ""),
@@ -940,8 +942,8 @@ class ShareDetailsByIdAPIHandler(BaseHandler):
940
942
  "modify_users": modify_users if modify_users is not None else [],
941
943
  "url": f"/shared/{share['id']}",
942
944
  "paths": share.get("paths", []),
943
- "has_token": share.get("secret_token") is not None,
944
- "secret_token": share.get("secret_token"),
945
+ "has_token": token is not None,
946
+ "secret_token": token if show_secret else None,
945
947
  "share_type": share.get("share_type", "static"),
946
948
  "tag_name": share.get("tag_name"),
947
949
  "allow_list": share.get("allow_list", []),
@@ -950,6 +952,10 @@ class ShareDetailsByIdAPIHandler(BaseHandler):
950
952
  "download_count": share_service.get_download_count(
951
953
  db_conn, share_id
952
954
  ),
955
+ "is_owner": self.is_share_owner(share),
956
+ "can_revoke": self.is_share_owner(share),
957
+ "can_manage": self.is_share_owner(share),
958
+ "can_edit_paths": self.can_edit_share_paths(share),
953
959
  }
954
960
 
955
961
  return {"share": share_info}
@@ -970,12 +976,14 @@ def _classify_share_for_user(
970
976
  allowed_list = allowed_raw if isinstance(allowed_raw, list) else []
971
977
  mod_list = share.get("modify_users") or []
972
978
 
973
- if current_user and current_user in mod_list:
974
- return True, False
975
- if creator and login_matches_share_creator_field(creator, current_user):
979
+ if creator and current_user and login_matches_share_creator_field(
980
+ creator, current_user
981
+ ):
976
982
  return True, False
977
983
  if not creator and is_admin:
978
984
  return True, False
985
+ if current_user and current_user in mod_list:
986
+ return False, True
979
987
  if not creator and current_user and (
980
988
  allowed_raw is None
981
989
  or (isinstance(allowed_raw, list) and current_user in allowed_list)
@@ -986,6 +994,16 @@ def _classify_share_for_user(
986
994
  return False, False
987
995
 
988
996
 
997
+ def _attach_share_capabilities(handler: BaseHandler, share: dict) -> dict:
998
+ """Add ownership / editor flags for the share management UI."""
999
+ out = dict(share)
1000
+ out["is_owner"] = handler.is_share_owner(share)
1001
+ out["can_revoke"] = handler.is_share_owner(share)
1002
+ out["can_manage"] = handler.is_share_owner(share)
1003
+ out["can_edit_paths"] = handler.can_edit_share_paths(share)
1004
+ return out
1005
+
1006
+
989
1007
  class ShareListAPIHandler(BaseHandler):
990
1008
  @tornado.web.authenticated
991
1009
  def get(self):
@@ -1010,9 +1028,10 @@ class ShareListAPIHandler(BaseHandler):
1010
1028
  for sid, share in all_shares.items():
1011
1029
  is_mine, is_shared = _classify_share_for_user(share, current_user, is_admin)
1012
1030
  if is_mine:
1013
- my_shares[sid] = share
1031
+ my_shares[sid] = _attach_share_capabilities(self, share)
1014
1032
  elif is_shared:
1015
- shared_with_me.append(_redact_share_secret_token(share))
1033
+ redacted = _redact_share_secret_token(share)
1034
+ shared_with_me.append(_attach_share_capabilities(self, redacted))
1016
1035
 
1017
1036
  self.write({"shares": my_shares, "shared_with_me": shared_with_me})
1018
1037
 
@@ -895,6 +895,7 @@ class BaseHandler(tornado.web.RequestHandler):
895
895
  namespace.setdefault("nav_title", "")
896
896
  namespace.setdefault("show_admin_link", False)
897
897
  namespace.setdefault("ldap_enabled", self.settings.get("ldap_server") is not None)
898
+ namespace.setdefault("static_version", constants_module.APP_VERSION)
898
899
  return namespace
899
900
 
900
901
  def get_current_user(self):
@@ -954,15 +955,27 @@ class BaseHandler(tornado.web.RequestHandler):
954
955
  return _display_username_from_dict(user)
955
956
  return _display_username_from_legacy(user, self)
956
957
 
957
- def can_manage_share_secrets(self, share: dict) -> bool:
958
- """True if current user may view raw secret tokens and full management details."""
958
+ def is_share_owner(self, share: dict) -> bool:
959
+ """True if the current user created the share (or is admin for legacy rows)."""
959
960
  if self.is_admin_user():
960
961
  return True
961
962
  u = get_username_string_for_db(self)
962
963
  if not u:
963
964
  return False
964
965
  creator = (share.get("created_by") or "").strip()
965
- if creator and login_matches_share_creator_field(creator, u):
966
+ if not creator:
967
+ return False
968
+ return login_matches_share_creator_field(creator, u)
969
+
970
+ def can_edit_share_paths(self, share: dict) -> bool:
971
+ """True if the user may add/remove files on an existing share."""
972
+ if self.is_share_owner(share):
966
973
  return True
967
- modify_users = share.get("modify_users") or []
968
- return u in modify_users
974
+ u = get_username_string_for_db(self)
975
+ if not u:
976
+ return False
977
+ return u in (share.get("modify_users") or [])
978
+
979
+ def can_manage_share_secrets(self, share: dict) -> bool:
980
+ """True for share owners: tokens, ACL, revoke, and full settings."""
981
+ return self.is_share_owner(share)
@@ -540,8 +540,19 @@ class UploadHandler(BaseHandler):
540
540
  username = self.get_display_username()
541
541
  self._session_dir = _upload_session_dir(username, chunk_info["upload_id"])
542
542
  offset = chunk_info["offset"]
543
+ # Only reset session on first chunk when no other parts exist (avoid wiping
544
+ # in-flight parallel chunks if chunk 0 is retried by the browser or proxy).
543
545
  if offset == 0 and os.path.isdir(self._session_dir):
544
- _remove_upload_session(self._session_dir)
546
+ try:
547
+ other_parts = [
548
+ n
549
+ for n in os.listdir(self._session_dir)
550
+ if n.endswith(".part") and n != "0.part"
551
+ ]
552
+ except OSError:
553
+ other_parts = []
554
+ if not other_parts:
555
+ _remove_upload_session(self._session_dir)
545
556
  os.makedirs(self._session_dir, exist_ok=True)
546
557
  self._temp_path = _chunk_file_path(self._session_dir, offset)
547
558
  self._aiofile = await aiofiles.open(self._temp_path, "wb")
@@ -283,6 +283,32 @@ def _is_user_allowed_for_modify(share, get_secure_cookie):
283
283
  return (True, None)
284
284
 
285
285
 
286
+ def _collect_files_for_share_paths(root: str, paths: list, *, include_missing_files: bool) -> list[str]:
287
+ """Resolve share path entries to relative file paths under *root*."""
288
+ collected: list[str] = []
289
+ for rel_path in paths or []:
290
+ try:
291
+ full_path = os.path.abspath(os.path.join(root, rel_path))
292
+ if not is_within_root(full_path, root):
293
+ continue
294
+ if os.path.isdir(full_path):
295
+ for sub in get_all_files_recursive(full_path, rel_path):
296
+ collected.append(str(sub).replace("\\", "/"))
297
+ elif os.path.isfile(full_path):
298
+ collected.append(str(rel_path).replace("\\", "/"))
299
+ elif include_missing_files:
300
+ collected.append(str(rel_path).replace("\\", "/"))
301
+ except Exception:
302
+ logging.debug("Skipping share path %r", rel_path, exc_info=True)
303
+ seen: set[str] = set()
304
+ unique: list[str] = []
305
+ for path in collected:
306
+ if path not in seen:
307
+ seen.add(path)
308
+ unique.append(path)
309
+ return unique
310
+
311
+
286
312
  def _get_share_file_list(share, db_conn=None):
287
313
  """Return list of file paths for the share (dynamic, static, or tag-based)."""
288
314
  share_type = share.get("share_type", "static")
@@ -295,32 +321,13 @@ def _get_share_file_list(share, db_conn=None):
295
321
  db_conn, share.get("tag_name"), root, allow_list, avoid_list
296
322
  )
297
323
 
324
+ paths = share.get("paths") or []
298
325
  if share_type == "dynamic":
299
- dynamic_files = []
300
- for folder_path in share.get("paths") or []:
301
- try:
302
- full_path = os.path.abspath(os.path.join(root, folder_path))
303
- if os.path.isdir(full_path) and is_within_root(full_path, root):
304
- all_files = get_all_files_recursive(full_path, folder_path)
305
- dynamic_files.extend(all_files)
306
- except Exception:
307
- logging.debug(
308
- "Skipping dynamic share folder %r", folder_path, exc_info=True
309
- )
310
- return filter_files_by_patterns(dynamic_files, allow_list, avoid_list)
326
+ resolved = _collect_files_for_share_paths(root, paths, include_missing_files=False)
327
+ return filter_files_by_patterns(resolved, allow_list, avoid_list)
311
328
 
312
- static_paths = []
313
- for rel_path in share.get("paths") or []:
314
- try:
315
- full_path = os.path.abspath(os.path.join(root, rel_path))
316
- if os.path.isdir(full_path):
317
- continue
318
- except Exception:
319
- logging.debug(
320
- "Skipping static share path check for %r", rel_path, exc_info=True
321
- )
322
- static_paths.append(rel_path)
323
- return filter_files_by_patterns(static_paths, allow_list, avoid_list)
329
+ resolved = _collect_files_for_share_paths(root, paths, include_missing_files=True)
330
+ return filter_files_by_patterns(resolved, allow_list, avoid_list)
324
331
 
325
332
 
326
333
  def _is_path_in_share(share, path, db_conn=None):
@@ -557,6 +564,21 @@ def _execute_share_update(handler, db_conn, share_id, share_data, data):
557
564
  )
558
565
 
559
566
 
567
+ _EDITOR_SHARE_UPDATE_KEYS = frozenset({"share_id", "paths", "remove_files"})
568
+
569
+
570
+ def _share_update_payload_for_user(handler, share_data: dict, data: dict) -> dict | None:
571
+ """Return update body allowed for this user, or None if forbidden."""
572
+ if handler.can_manage_share_secrets(share_data):
573
+ return data
574
+ if not handler.can_edit_share_paths(share_data):
575
+ return None
576
+ filtered = {k: v for k, v in data.items() if k in _EDITOR_SHARE_UPDATE_KEYS}
577
+ if not any(k in filtered for k in ("paths", "remove_files")):
578
+ return None
579
+ return filtered
580
+
581
+
560
582
  def _apply_metadata_updates(data, share_data, update_fields):
561
583
  """Apply users/token/filters/expiry metadata to update_fields."""
562
584
  if "allowed_users" in data:
@@ -718,9 +740,9 @@ class ShareRevokeHandler(XSRFTokenMixin, BaseHandler):
718
740
  self.set_status(404)
719
741
  self.write({"error": "Share not found"})
720
742
  return
721
- if not self.can_manage_share_secrets(share):
743
+ if not self.is_share_owner(share):
722
744
  self.set_status(403)
723
- self.write({"error": "Access denied"})
745
+ self.write({"error": "Only the share owner can revoke this share"})
724
746
  return
725
747
  self.get_service("share_service").delete_share(self.db_conn, sid)
726
748
  self.get_service("audit_service").log(
@@ -759,7 +781,8 @@ class ShareUpdateHandler(XSRFTokenMixin, BaseHandler):
759
781
  self.set_status(err_status)
760
782
  self.write(err_body)
761
783
  return None, None, []
762
- if not self.can_manage_share_secrets(share_data):
784
+ data = _share_update_payload_for_user(self, share_data, data)
785
+ if data is None:
763
786
  self.set_status(403)
764
787
  self.write({"error": "Access denied"})
765
788
  return None, None, []
@@ -5,10 +5,15 @@ import socket
5
5
  import sqlite3
6
6
  import ssl
7
7
 
8
+ import tornado.httpserver
8
9
  import tornado.ioloop
10
+ import tornado.netutil
11
+ import tornado.process
9
12
  import tornado.web
10
13
  import logging.handlers
11
14
 
15
+ from aird.server_runtime import describe_worker_layout, resolve_worker_count
16
+
12
17
 
13
18
  import aird.constants as constants
14
19
  import aird.config as config
@@ -486,42 +491,87 @@ def _build_app_context() -> AppContext:
486
491
  )
487
492
 
488
493
 
489
- def _start_server(app, ssl_options, port: int, hostname: str) -> None:
494
+ def _build_application():
495
+ """Create the Tornado app (call after _init_database in each process)."""
496
+ cookie_secret = os.environ.get("AIRD_COOKIE_SECRET") or secrets.token_urlsafe(64)
497
+ settings = {
498
+ "cookie_secret": cookie_secret,
499
+ "xsrf_cookies": True,
500
+ "login_url": "/login",
501
+ "admin_login_url": "/admin/login",
502
+ "cloud_manager": constants.CLOUD_MANAGER,
503
+ }
504
+ settings["app_context"] = _build_app_context()
505
+ return make_app(
506
+ settings,
507
+ config.LDAP_ENABLED,
508
+ config.LDAP_SERVER,
509
+ config.LDAP_BASE_DN,
510
+ config.LDAP_USER_TEMPLATE,
511
+ config.LDAP_FILTER_TEMPLATE,
512
+ config.LDAP_ATTRIBUTES,
513
+ config.LDAP_ATTRIBUTE_MAP,
514
+ config.ADMIN_USERS,
515
+ )
516
+
517
+
518
+ def _run_http_server(app, ssl_options, sockets) -> None:
519
+ server = tornado.httpserver.HTTPServer(
520
+ app,
521
+ ssl_options=ssl_options,
522
+ max_body_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
523
+ max_buffer_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
524
+ )
525
+ server.add_sockets(sockets)
526
+ if tornado.process.task_id() in (0, None):
527
+ tornado.ioloop.IOLoop.current().call_later(
528
+ 3600, _run_cleanup_expired_shares
529
+ )
530
+ tornado.ioloop.IOLoop.current().start()
531
+
532
+
533
+ def _start_server(ssl_options, port: int, hostname: str, worker_count: int) -> None:
490
534
  _MAX_PORT_RETRIES = 3
535
+ proto = "https" if ssl_options else "http"
491
536
  for attempt in range(_MAX_PORT_RETRIES):
492
537
  try:
493
- if ssl_options:
494
- proto = "https"
495
- app.listen(
496
- port,
497
- ssl_options=ssl_options,
498
- max_body_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
499
- max_buffer_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
500
- )
538
+ sockets = tornado.netutil.bind_sockets(port, address="")
539
+ if worker_count <= 1:
501
540
  logger.info(
502
- f"Serving HTTPS on 0.0.0.0 port {port} ({proto}://0.0.0.0:{port}/) ..."
503
- )
504
- else:
505
- proto = "http"
506
- app.listen(
541
+ "Serving %s on 0.0.0.0 port %d (single process) ...",
542
+ proto.upper(),
507
543
  port,
508
- max_body_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
509
- max_buffer_size=constants.UPLOAD_REQUEST_MAX_BODY_SIZE,
510
- )
511
- logger.info(
512
- f"Serving HTTP on 0.0.0.0 port {port} ({proto}://0.0.0.0:{port}/) ..."
513
544
  )
514
- _print_server_urls(port, hostname, proto)
515
- tornado.ioloop.IOLoop.current().call_later(
516
- 3600, _run_cleanup_expired_shares
545
+ _init_database()
546
+ app = _build_application()
547
+ _print_server_urls(port, hostname, proto)
548
+ _run_http_server(app, ssl_options, sockets)
549
+ return
550
+
551
+ logger.info(
552
+ "Serving %s on 0.0.0.0 port %d (%s) ...",
553
+ proto.upper(),
554
+ port,
555
+ describe_worker_layout(worker_count),
517
556
  )
518
- tornado.ioloop.IOLoop.current().start()
557
+ logger.warning(
558
+ "Multiple workers: in-memory WebSocket/P2P state is per process; "
559
+ "use sticky sessions at the load balancer if needed."
560
+ )
561
+ tornado.process.fork_processes(worker_count)
562
+ _init_database()
563
+ app = _build_application()
564
+ if tornado.process.task_id() == 0:
565
+ _print_server_urls(port, hostname, proto)
566
+ _run_http_server(app, ssl_options, sockets)
519
567
  return
520
568
  except OSError:
521
569
  logger.exception("Failed to bind on port %d", port)
522
570
  if attempt < _MAX_PORT_RETRIES - 1:
523
571
  port += 1
524
- logger.warning("Retrying on port %d (%d/%d)", port, attempt + 2, _MAX_PORT_RETRIES)
572
+ logger.warning(
573
+ "Retrying on port %d (%d/%d)", port, attempt + 2, _MAX_PORT_RETRIES
574
+ )
525
575
  else:
526
576
  logger.error(
527
577
  "Could not bind after %d attempts. Set a different --port and retry.",
@@ -570,34 +620,12 @@ def main():
570
620
  else:
571
621
  logger.info("Single-user mode — all users share root: %s", constants.ROOT_DIR)
572
622
 
573
- cookie_secret = os.environ.get("AIRD_COOKIE_SECRET") or secrets.token_urlsafe(64)
574
623
  if not os.environ.get("AIRD_COOKIE_SECRET"):
624
+ os.environ["AIRD_COOKIE_SECRET"] = secrets.token_urlsafe(64)
575
625
  logger.warning(
576
626
  "cookie_secret is randomly generated; sessions will be invalidated on restart. "
577
627
  "Set the AIRD_COOKIE_SECRET environment variable for persistent sessions."
578
628
  )
579
- settings = {
580
- "cookie_secret": cookie_secret,
581
- "xsrf_cookies": True,
582
- "login_url": "/login",
583
- "admin_login_url": "/admin/login",
584
- "cloud_manager": constants.CLOUD_MANAGER,
585
- }
586
-
587
- _init_database()
588
- settings["app_context"] = _build_app_context()
589
-
590
- app = make_app(
591
- settings,
592
- config.LDAP_ENABLED,
593
- config.LDAP_SERVER,
594
- config.LDAP_BASE_DN,
595
- config.LDAP_USER_TEMPLATE,
596
- config.LDAP_FILTER_TEMPLATE,
597
- config.LDAP_ATTRIBUTES,
598
- config.LDAP_ATTRIBUTE_MAP,
599
- config.ADMIN_USERS,
600
- )
601
629
 
602
630
  ssl_options = None
603
631
  if config.SSL_CERT and config.SSL_KEY:
@@ -606,7 +634,8 @@ def main():
606
634
  ssl_context.load_cert_chain(config.SSL_CERT, config.SSL_KEY)
607
635
  ssl_options = ssl_context
608
636
 
609
- _start_server(app, ssl_options, config.PORT, config.HOSTNAME)
637
+ worker_count = resolve_worker_count(config.WORKERS)
638
+ _start_server(ssl_options, config.PORT, config.HOSTNAME, worker_count)
610
639
 
611
640
 
612
641
  if __name__ == "__main__":
@@ -0,0 +1,87 @@
1
+ """HTTP server process count and Tornado prefork helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import math
7
+ import os
8
+ import sys
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ def detect_threads_per_core() -> float:
14
+ """Logical CPUs per physical core (hyperthreading); default 2."""
15
+ raw = os.environ.get("AIRD_THREADS_PER_CORE", "").strip()
16
+ if raw:
17
+ try:
18
+ value = float(raw)
19
+ if value > 0:
20
+ return value
21
+ except ValueError:
22
+ logger.warning("Invalid AIRD_THREADS_PER_CORE=%r; using 2", raw)
23
+ return 2.0
24
+
25
+
26
+ def detect_physical_cpu_count() -> int:
27
+ """Best-effort physical core count; falls back to logical / threads_per_core."""
28
+ logical = os.cpu_count() or 1
29
+ threads_per_core = detect_threads_per_core()
30
+ if sys.platform == "linux":
31
+ try:
32
+ ids: set[int] = set()
33
+ with open("/proc/cpuinfo", encoding="utf-8", errors="replace") as cpuinfo:
34
+ for line in cpuinfo:
35
+ if line.lower().startswith("core id") or line.lower().startswith(
36
+ "cpu cores"
37
+ ):
38
+ _, _, val = line.partition(":")
39
+ ids.add(int(val.strip()))
40
+ if ids:
41
+ return max(1, len(ids))
42
+ except OSError:
43
+ pass
44
+ return max(1, round(logical / threads_per_core))
45
+
46
+
47
+ def compute_default_worker_count() -> int:
48
+ """
49
+ Process count = ceil(1.25 * threads_per_core * physical_cores).
50
+
51
+ Example: 4 physical cores, 2 threads/core -> ceil(1.25 * 2 * 4) = 10 workers.
52
+ """
53
+ threads_per_core = detect_threads_per_core()
54
+ physical = detect_physical_cpu_count()
55
+ return max(1, math.ceil(1.25 * threads_per_core * physical))
56
+
57
+
58
+ def resolve_worker_count(configured: int | None = None) -> int:
59
+ """CLI/config > env AIRD_WORKERS > computed default; 1 on Windows."""
60
+ if sys.platform == "win32":
61
+ if configured and configured > 1:
62
+ logger.warning(
63
+ "Multiprocess serving is not supported on Windows; using 1 worker"
64
+ )
65
+ return 1
66
+ if configured is not None and configured > 0:
67
+ return configured
68
+ env_workers = os.environ.get("AIRD_WORKERS", "").strip()
69
+ if env_workers:
70
+ try:
71
+ parsed = int(env_workers)
72
+ if parsed > 0:
73
+ return parsed
74
+ except ValueError:
75
+ logger.warning("Invalid AIRD_WORKERS=%r; using default formula", env_workers)
76
+ return compute_default_worker_count()
77
+
78
+
79
+ def describe_worker_layout(worker_count: int) -> str:
80
+ logical = os.cpu_count() or 1
81
+ tpc = detect_threads_per_core()
82
+ physical = detect_physical_cpu_count()
83
+ return (
84
+ f"workers={worker_count} "
85
+ f"(logical_cpus={logical}, physical_cpus={physical}, "
86
+ f"threads_per_core={tpc:g}, formula=ceil(1.25*tpc*physical))"
87
+ )