psychopy 2025.1.0__py3-none-any.whl → 2025.2.1__py3-none-any.whl

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.

Potentially problematic release.


This version of psychopy might be problematic. Click here for more details.

Files changed (226) hide show
  1. psychopy/VERSION +1 -1
  2. psychopy/alerts/alertsCatalogue/4810.yaml +19 -0
  3. psychopy/alerts/alertsCatalogue/alertCategories.yaml +4 -0
  4. psychopy/alerts/alertsCatalogue/alertmsg.py +15 -1
  5. psychopy/alerts/alertsCatalogue/generateAlertmsg.py +2 -2
  6. psychopy/app/Resources/classic/add_many.png +0 -0
  7. psychopy/app/Resources/classic/add_many@2x.png +0 -0
  8. psychopy/app/Resources/classic/devices.png +0 -0
  9. psychopy/app/Resources/classic/devices@2x.png +0 -0
  10. psychopy/app/Resources/classic/photometer.png +0 -0
  11. psychopy/app/Resources/classic/photometer@2x.png +0 -0
  12. psychopy/app/Resources/dark/add_many.png +0 -0
  13. psychopy/app/Resources/dark/add_many@2x.png +0 -0
  14. psychopy/app/Resources/dark/devices.png +0 -0
  15. psychopy/app/Resources/dark/devices@2x.png +0 -0
  16. psychopy/app/Resources/dark/photometer.png +0 -0
  17. psychopy/app/Resources/dark/photometer@2x.png +0 -0
  18. psychopy/app/Resources/light/add_many.png +0 -0
  19. psychopy/app/Resources/light/add_many@2x.png +0 -0
  20. psychopy/app/Resources/light/devices.png +0 -0
  21. psychopy/app/Resources/light/devices@2x.png +0 -0
  22. psychopy/app/Resources/light/photometer.png +0 -0
  23. psychopy/app/Resources/light/photometer@2x.png +0 -0
  24. psychopy/app/_psychopyApp.py +35 -13
  25. psychopy/app/builder/builder.py +88 -35
  26. psychopy/app/builder/dialogs/__init__.py +69 -220
  27. psychopy/app/builder/dialogs/dlgsCode.py +29 -8
  28. psychopy/app/builder/dialogs/paramCtrls.py +1468 -904
  29. psychopy/app/builder/validators.py +25 -17
  30. psychopy/app/coder/coder.py +12 -1
  31. psychopy/app/coder/repl.py +5 -2
  32. psychopy/app/colorpicker/__init__.py +1 -1
  33. psychopy/app/deviceManager/__init__.py +1 -0
  34. psychopy/app/deviceManager/addDialog.py +218 -0
  35. psychopy/app/deviceManager/dialog.py +185 -0
  36. psychopy/app/deviceManager/panel.py +191 -0
  37. psychopy/app/deviceManager/utils.py +60 -0
  38. psychopy/app/idle.py +7 -0
  39. psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
  40. psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +12695 -10592
  41. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
  42. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.po +10199 -24
  43. psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
  44. psychopy/app/locale/da_DK/LC_MESSAGE/messages.po +10199 -24
  45. psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
  46. psychopy/app/locale/de_DE/LC_MESSAGE/messages.po +11221 -9712
  47. psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
  48. psychopy/app/locale/el_GR/LC_MESSAGE/messages.po +10200 -25
  49. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
  50. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.po +10200 -25
  51. psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
  52. psychopy/app/locale/en_US/LC_MESSAGE/messages.po +10195 -18
  53. psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
  54. psychopy/app/locale/es_CO/LC_MESSAGE/messages.po +11917 -9101
  55. psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
  56. psychopy/app/locale/es_ES/LC_MESSAGE/messages.po +11924 -9103
  57. psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
  58. psychopy/app/locale/es_US/LC_MESSAGE/messages.po +11917 -9101
  59. psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
  60. psychopy/app/locale/et_EE/LC_MESSAGE/messages.po +11084 -9569
  61. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
  62. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.po +11590 -5806
  63. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
  64. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.po +10199 -24
  65. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
  66. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.po +11091 -9577
  67. psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
  68. psychopy/app/locale/he_IL/LC_MESSAGE/messages.po +11072 -9549
  69. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
  70. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.po +11071 -9559
  71. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
  72. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.po +10200 -25
  73. psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
  74. psychopy/app/locale/it_IT/LC_MESSAGE/messages.po +11072 -9560
  75. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
  76. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.po +1485 -1137
  77. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
  78. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.po +10199 -24
  79. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
  80. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.po +11463 -8757
  81. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
  82. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.po +10200 -25
  83. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
  84. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.po +10200 -25
  85. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
  86. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.po +10200 -25
  87. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
  88. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.po +11288 -9434
  89. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
  90. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.po +10200 -25
  91. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
  92. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.po +10199 -24
  93. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
  94. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.po +11441 -8747
  95. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
  96. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.po +11069 -9545
  97. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
  98. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.po +12085 -8268
  99. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
  100. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.po +11929 -8022
  101. psychopy/app/plugin_manager/dialog.py +12 -3
  102. psychopy/app/plugin_manager/packageIndex.py +303 -0
  103. psychopy/app/plugin_manager/packages.py +203 -63
  104. psychopy/app/plugin_manager/plugins.py +120 -240
  105. psychopy/app/preferencesDlg.py +6 -1
  106. psychopy/app/psychopyApp.py +16 -4
  107. psychopy/app/runner/runner.py +10 -2
  108. psychopy/app/runner/scriptProcess.py +8 -3
  109. psychopy/app/stdout/stdOutRich.py +11 -4
  110. psychopy/app/themes/icons.py +3 -0
  111. psychopy/app/utils.py +61 -0
  112. psychopy/colors.py +10 -5
  113. psychopy/data/experiment.py +133 -23
  114. psychopy/data/routine.py +12 -0
  115. psychopy/data/staircase.py +42 -20
  116. psychopy/data/trial.py +20 -12
  117. psychopy/data/utils.py +43 -3
  118. psychopy/demos/builder/Experiments/dragAndDrop/drag_and_drop.psyexp +22 -5
  119. psychopy/demos/builder/Experiments/dragAndDrop/stimuli/solutions.xlsx +0 -0
  120. psychopy/demos/builder/Experiments/stroopVoice/stroopVoice.psyexp +2 -12
  121. psychopy/demos/builder/Feature Demos/buttonBox/buttonBoxDemo.psyexp +3 -8
  122. psychopy/demos/builder/Feature Demos/movies/movie.psyexp +220 -0
  123. psychopy/demos/builder/Feature Demos/movies/readme.md +3 -0
  124. psychopy/demos/builder/Feature Demos/visualValidator/visualValidator.psyexp +1 -2
  125. psychopy/demos/builder/Hardware/camera/camera.psyexp +3 -16
  126. psychopy/demos/builder/Hardware/microphone/microphone.psyexp +3 -16
  127. psychopy/demos/coder/hardware/hdf5_extract.py +133 -0
  128. psychopy/event.py +20 -15
  129. psychopy/experiment/_experiment.py +86 -10
  130. psychopy/experiment/components/__init__.py +3 -10
  131. psychopy/experiment/components/_base.py +9 -20
  132. psychopy/experiment/components/button/__init__.py +1 -1
  133. psychopy/experiment/components/buttonBox/__init__.py +50 -54
  134. psychopy/experiment/components/camera/__init__.py +137 -359
  135. psychopy/experiment/components/keyboard/__init__.py +17 -24
  136. psychopy/experiment/components/microphone/__init__.py +61 -110
  137. psychopy/experiment/components/movie/__init__.py +2 -3
  138. psychopy/experiment/components/serialOut/__init__.py +192 -93
  139. psychopy/experiment/components/settings/__init__.py +45 -27
  140. psychopy/experiment/components/sound/__init__.py +82 -73
  141. psychopy/experiment/components/soundsensor/__init__.py +43 -80
  142. psychopy/experiment/devices.py +303 -0
  143. psychopy/experiment/exports.py +20 -18
  144. psychopy/experiment/flow.py +7 -0
  145. psychopy/experiment/loops.py +47 -29
  146. psychopy/experiment/monitor.py +74 -0
  147. psychopy/experiment/params.py +48 -10
  148. psychopy/experiment/plugins.py +28 -108
  149. psychopy/experiment/py2js_transpiler.py +1 -1
  150. psychopy/experiment/routines/__init__.py +1 -1
  151. psychopy/experiment/routines/_base.py +59 -24
  152. psychopy/experiment/routines/audioValidator/__init__.py +19 -155
  153. psychopy/experiment/routines/visualValidator/__init__.py +25 -25
  154. psychopy/hardware/__init__.py +20 -57
  155. psychopy/hardware/button.py +15 -2
  156. psychopy/hardware/camera/__init__.py +2237 -1394
  157. psychopy/hardware/joystick/__init__.py +1 -1
  158. psychopy/hardware/keyboard.py +5 -8
  159. psychopy/hardware/listener.py +4 -1
  160. psychopy/hardware/manager.py +75 -35
  161. psychopy/hardware/microphone.py +53 -7
  162. psychopy/hardware/monitor.py +144 -0
  163. psychopy/hardware/photometer/__init__.py +156 -117
  164. psychopy/hardware/serialdevice.py +16 -2
  165. psychopy/hardware/soundsensor.py +4 -1
  166. psychopy/iohub/devices/deviceConfigValidation.py +2 -1
  167. psychopy/iohub/devices/eyetracker/hw/gazepoint/__init__.py +2 -2
  168. psychopy/iohub/devices/eyetracker/hw/gazepoint/gp3/__init__.py +1 -0
  169. psychopy/iohub/devices/eyetracker/hw/gazepoint/gp3/eyetracker.py +10 -0
  170. psychopy/iohub/devices/keyboard/darwin.py +8 -5
  171. psychopy/iohub/util/__init__.py +7 -8
  172. psychopy/localization/generateTranslationTemplate.py +208 -116
  173. psychopy/localization/messages.pot +4305 -3502
  174. psychopy/monitors/MonitorCenter.py +174 -74
  175. psychopy/plugins/__init__.py +6 -4
  176. psychopy/preferences/devices.py +80 -0
  177. psychopy/preferences/generateHints.py +2 -1
  178. psychopy/preferences/preferences.py +35 -11
  179. psychopy/scripts/psychopy-pkgutil.py +969 -0
  180. psychopy/scripts/psyexpCompile.py +1 -1
  181. psychopy/session.py +34 -38
  182. psychopy/sound/__init__.py +6 -260
  183. psychopy/sound/audioclip.py +164 -0
  184. psychopy/sound/backend_ptb.py +8 -0
  185. psychopy/sound/backend_pygame.py +10 -0
  186. psychopy/sound/backend_pysound.py +9 -0
  187. psychopy/sound/backends/__init__.py +0 -0
  188. psychopy/sound/microphone.py +3 -0
  189. psychopy/sound/sound.py +58 -0
  190. psychopy/tests/data/correctScript/python/correctNoiseStimComponent.py +1 -1
  191. psychopy/tests/data/duplicateHeaders.csv +2 -0
  192. psychopy/tests/test_app/test_builder/test_BuilderFrame.py +22 -7
  193. psychopy/tests/test_app/test_builder/test_CompileFromBuilder.py +0 -2
  194. psychopy/tests/test_data/test_utils.py +5 -1
  195. psychopy/tests/test_experiment/test_components/test_ButtonBoxComponent.py +22 -2
  196. psychopy/tests/test_hardware/test_ports.py +0 -12
  197. psychopy/tests/test_tools/test_stringtools.py +1 -1
  198. psychopy/tools/attributetools.py +12 -5
  199. psychopy/tools/fontmanager.py +17 -14
  200. psychopy/tools/gltools.py +4 -2
  201. psychopy/tools/movietools.py +43 -2
  202. psychopy/tools/stringtools.py +33 -8
  203. psychopy/tools/versionchooser.py +1 -1
  204. psychopy/validation/audio.py +5 -1
  205. psychopy/validation/visual.py +5 -1
  206. psychopy/visual/basevisual.py +8 -7
  207. psychopy/visual/circle.py +2 -2
  208. psychopy/visual/helpers.py +3 -1
  209. psychopy/visual/image.py +29 -109
  210. psychopy/visual/movies/__init__.py +1800 -313
  211. psychopy/visual/polygon.py +4 -0
  212. psychopy/visual/shape.py +2 -2
  213. psychopy/visual/window.py +35 -12
  214. psychopy/voicekey/__init__.py +41 -669
  215. psychopy/voicekey/labjack_vks.py +7 -48
  216. psychopy/voicekey/parallel_vks.py +7 -42
  217. psychopy/voicekey/vk_tools.py +114 -263
  218. {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/METADATA +20 -13
  219. {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/RECORD +222 -190
  220. {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/WHEEL +1 -1
  221. psychopy/visual/movies/players/__init__.py +0 -62
  222. psychopy/visual/movies/players/ffpyplayer_player.py +0 -1401
  223. psychopy/voicekey/demo_vks.py +0 -12
  224. psychopy/voicekey/signal.py +0 -42
  225. {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/entry_points.txt +0 -0
  226. {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,969 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+
4
+ """Module to manage packages in PsychoPy.
5
+ """
6
+
7
+ __version__ = '0.1.0'
8
+
9
+ import argparse
10
+ import os
11
+ import time
12
+ import requests
13
+ import json
14
+ import subprocess
15
+ import re
16
+ import sys
17
+
18
+ from bs4 import BeautifulSoup
19
+ from packaging.version import Version
20
+
21
+ # get the psychopuy user base from the environment variable
22
+ _userBaseEnvVal = os.environ.get('PYTHONUSERBASE', None)
23
+ if _userBaseEnvVal is not None:
24
+ PYTHONUSERBASE = _userBaseEnvVal # user base directory from environment variable
25
+ else:
26
+ import site
27
+ PYTHONUSERBASE = site.getuserbase() # user base directory from site module
28
+ print(f'[notice]: No PYTHONUSERBASE environment variable found. Using '
29
+ f'interpreter default: {PYTHONUSERBASE}')
30
+
31
+ # root user preferences dir
32
+ _userAppPrefDir = os.environ.get('PSYCHOPYUSERPREFDIR', None)
33
+ if _userAppPrefDir is not None:
34
+ PSYCHOPYUSERPREFDIR = _userAppPrefDir # user app preferences dir from environment variable
35
+
36
+ PYPI_SIMPLE_INDEX_URL = "https://pypi.org/simple/"
37
+ PACKAGE_INDEX_FILE = "psychopy_packages.json"
38
+
39
+ # Storage for package indices
40
+ packageCache = None # initialized later
41
+
42
+ # time elapsed from the previous update before the index is considered stale
43
+ _indexStaleAfter = 28 * 24 * 3600 # every 4 weeks
44
+
45
+
46
+ # ------------------------------------------------------------------------------
47
+ # Functions for managing the package index
48
+ #
49
+
50
+ def setStaleTime(days):
51
+ """Set the amount of time before the package index is considered stale.
52
+
53
+ Parameters
54
+ ----------
55
+ days : float or int
56
+ The amount of time elapsed from the previous update before the index is
57
+ considered stale and in need of an update. If `updatePackageIndex` is
58
+ called with `fetch=False`, the index will be updated if the last update
59
+ was more than this amount of time ago. The default is 28 days.
60
+
61
+ """
62
+ global _indexStaleAfter
63
+ _indexStaleAfter = days * 24 * 3600 # hours to seconds
64
+ print('[notice]: Package index stale time set to:', days, 'days')
65
+
66
+
67
+ def getStaleTime():
68
+ """Get the amount of time before the package index cache is considered
69
+ stale.
70
+
71
+ Returns
72
+ -------
73
+ float
74
+ The amount of time elapsed from the previous update before the index is
75
+ considered stale and in need of an update. If `updatePackageIndex` is
76
+ called with `fetch=False`, the index will be updated if the last update
77
+ was more than this amount of time ago. The default is 28 days.
78
+
79
+ """
80
+ global _indexStaleAfter
81
+ return _indexStaleAfter / (24 * 3600) # seconds to days
82
+
83
+
84
+ def setUserBase(userBase):
85
+ """Set the user base directory.
86
+
87
+ Parameters
88
+ ----------
89
+ userBase : str
90
+ User base directory.
91
+
92
+ """
93
+ global PYTHONUSERBASE
94
+
95
+ # check if the user base directory is valid
96
+ if not os.path.exists(userBase):
97
+ raise ValueError(
98
+ f"[error]: User base directory '{userBase}' does not exist.")
99
+
100
+ # set the user base directory
101
+ PYTHONUSERBASE = userBase
102
+ print('[notice]: User base directory set to:', PYTHONUSERBASE)
103
+
104
+
105
+ def getUserBase():
106
+ """Get the user base directory.
107
+
108
+ Returns
109
+ -------
110
+ str
111
+ User base directory.
112
+
113
+ """
114
+ global PYTHONUSERBASE
115
+ return PYTHONUSERBASE
116
+
117
+
118
+ def setLockFile():
119
+ """Set the lock file for the package index.
120
+
121
+ This function creates a lock file in the user base directory to prevent
122
+ multiple processes from updating the package index at the same time.
123
+
124
+ """
125
+ global PACKAGE_INDEX_FILE
126
+ indexPath = os.path.dirname(os.path.abspath(PACKAGE_INDEX_FILE))
127
+ lockFile = os.path.join(indexPath, 'psychopy_packages.lock')
128
+
129
+ print('[notice]: Setting lock file for package index at:', lockFile)
130
+
131
+ # check if the lock file already exists
132
+ if os.path.exists(lockFile):
133
+ print(f"[error]: Lock file '{lockFile}' already exists.")
134
+ return False
135
+
136
+ # create the lock file
137
+ with open(lockFile, 'w') as f:
138
+ f.write('Lock file for PsychoPy package index.')
139
+
140
+ print(f'[notice]: Lock file created at: {lockFile}')
141
+ return True
142
+
143
+
144
+ def freeLockFile():
145
+ """Free the lock file for the package index.
146
+
147
+ This function removes the lock file in the user base directory to allow
148
+ other processes to update the package index.
149
+
150
+ """
151
+ global PACKAGE_INDEX_FILE
152
+ indexPath = os.path.dirname(os.path.abspath(PACKAGE_INDEX_FILE))
153
+ lockFile = os.path.join(indexPath, 'psychopy_packages.lock')
154
+
155
+ # check if the lock file exists
156
+ if os.path.exists(lockFile):
157
+ os.remove(lockFile)
158
+ print(f'[notice]: Lock file removed: {lockFile}')
159
+ else:
160
+ print(f"[error]: Lock file '{lockFile}' does not exist.")
161
+ return False
162
+
163
+ return True
164
+
165
+
166
+ def setPackageIndexFilePath(indexFile):
167
+ """Set the location for the index file.
168
+ """
169
+ global PACKAGE_INDEX_FILE
170
+ PACKAGE_INDEX_FILE = os.path.abspath(indexFile)
171
+
172
+ print(f'[notice]: Package index file set to: {PACKAGE_INDEX_FILE}')
173
+
174
+
175
+ def getPackageIndexFilePath():
176
+ """Get the location for the index file.
177
+
178
+ Returns
179
+ -------
180
+ str
181
+ The location of the index file.
182
+
183
+ """
184
+ global PACKAGE_INDEX_FILE
185
+ return PACKAGE_INDEX_FILE
186
+
187
+
188
+ def _getRemoteFileSize(url):
189
+ """Get the size of the remote file from its header.
190
+
191
+ Parameters
192
+ ----------
193
+ url : str
194
+ The URL of the remote file.
195
+
196
+ Returns
197
+ -------
198
+ int or None
199
+ Size of the file in bytes. If the file does not exist or the size cannot
200
+ be determined, returns -1.
201
+
202
+ """
203
+ # check the size of the remote file
204
+ try:
205
+ response = requests.head(url)
206
+ if response.status_code != 200:
207
+ print(f"[error]: Failed to fetch {url}: {response.status_code}")
208
+ return -1
209
+ except requests.RequestException as e:
210
+ print(f"[error]: Failed to fetch {url}: {e}")
211
+ return -1
212
+
213
+ return int(response.headers.get('Content-Length', -1))
214
+
215
+
216
+ def _fetchPluginIndex(fetch=False):
217
+ """Fetch the official PsychoPy plugin index.
218
+
219
+ This function fetches the PsychoPy plugin index from the official URL and
220
+ updates the local package cache. It checks if the remote file has changed
221
+ in size from the last download, or if the local data is stale. If either of
222
+ these cases are true, it downloads the file and updates the local cache.
223
+
224
+ Parameters
225
+ ----------
226
+ fetch : bool
227
+ Refresh index even if local cache is up-to-date. Default is False.
228
+
229
+ """
230
+ global packageCache
231
+ url = "https://psychopy.org/plugins.json" # url for plugins
232
+
233
+ # check if the remote file has changed or local data is stale
234
+ remoteFileSize = _getRemoteFileSize(url)
235
+ if not fetch:
236
+ localFileSize = packageCache['available']['plugins'].get('lastsize', -1)
237
+ lastUpdated = packageCache['available']['plugins'].get('lastupdated', -1)
238
+ indexStale = lastUpdated < time.time() - _indexStaleAfter
239
+ if remoteFileSize == localFileSize and not indexStale:
240
+ print(f'Local PsychoPy plugin index is up-to-date.')
241
+ return
242
+
243
+ # download the file
244
+ print(f'Fetching PsychoPy plugin index from URL: {url}')
245
+ downloadStartTime = time.time()
246
+ response = requests.get(url)
247
+ if response.status_code != 200:
248
+ print(f"[error]: Failed to fetch {url}: {response.status_code}")
249
+ return
250
+
251
+ print(f'Completed fetching remote PsychoPy plugin index (took '
252
+ f'{round(time.time() - downloadStartTime, 4)} seconds)')
253
+
254
+ # parse the JSON content
255
+ print('Parsing downloaded PsychoPy plugin index...')
256
+ parseStartTime = time.time()
257
+ try:
258
+ pluginIndexJSON = json.loads(response.text)
259
+ except json.JSONDecodeError:
260
+ print(f"[error]: Failed to decode JSON from {url}.")
261
+ return False
262
+
263
+ print(f'Completed parsing PsychoPy plugin index (took '
264
+ f'{round(time.time() - parseStartTime, 4)} seconds)')
265
+ print(f'Found {len(pluginIndexJSON)} packages in the remote plugin index.')
266
+
267
+ # add fields to the package index
268
+ for pluginInfo in pluginIndexJSON:
269
+ pipName = pluginInfo['pipname']
270
+ packageCache['available']['plugins']['packages'][pipName] = pluginInfo
271
+
272
+ # get installed version by looking up local package cache
273
+ packageVersion = packageCache['installed']['user']['packages'].get(pipName, None)
274
+ packageCache['available']['plugins']['packages'][pipName]['version'] = packageVersion
275
+
276
+ # get remote versions
277
+ versions = getPackageVersions(pipName)
278
+ packageCache['available']['plugins']['packages'][pipName]['releases'] = versions
279
+
280
+ packageCache['available']['plugins']['lastupdated'] = time.time()
281
+ packageCache['available']['plugins']['lastsize'] = remoteFileSize
282
+
283
+
284
+ def updatePackageIndex(fetch=True):
285
+ """Update the package index.
286
+
287
+ Parameters
288
+ ----------
289
+ packageIndex : str
290
+ The package index to update. Default is 'PyPI'.
291
+ fetch : bool
292
+ Whether to fetch a fresh package index from the URL. If False, the
293
+ locally cached package index will be used if available. If the local
294
+ file is not present and fetch is False, the index will be updated.
295
+
296
+ """
297
+ global packageCache
298
+
299
+ setLockFile() # set the lock file to prevent multiple processes from updating
300
+
301
+ updateStartTime = time.time()
302
+
303
+ # setup package cache structure for JSON file
304
+ packageCache = {
305
+ 'installed': {},
306
+ 'available': { # list of installable packages from remote locations
307
+ 'remote': {
308
+ 'PyPI': {
309
+ 'name': 'Python Package Index (PyPI)', # human readable name
310
+ 'url': PYPI_SIMPLE_INDEX_URL, # root index URL
311
+ 'doctype': 'pypi-simple-index-html', # parser type, future feature
312
+ 'lastupdated': -1.0,
313
+ 'packages': [],
314
+ },
315
+ },
316
+ 'plugins': {
317
+ 'name': 'PsychoPy Offical Plugin Index',
318
+ 'lastupdated': -1,
319
+ 'lastsize': -1, # from header, used to check if updated
320
+ 'url': "https://psychopy.org/plugins.json",
321
+ 'doctype': 'psychopy-plugin-json',
322
+ 'packages': {},
323
+ }
324
+ },
325
+ }
326
+
327
+ # check if we have a local index file
328
+ localData = None
329
+ if not os.path.exists(PACKAGE_INDEX_FILE):
330
+ print(f"[notice]: Package index file '{PACKAGE_INDEX_FILE}' not found. "
331
+ f"Creating a new one...")
332
+ else:
333
+ # load the file to get remote repo data
334
+ with open(PACKAGE_INDEX_FILE, 'r') as f:
335
+ print(f"Reading package index from {PACKAGE_INDEX_FILE}...")
336
+ try:
337
+ localData = json.load(f)
338
+ except json.JSONDecodeError:
339
+ print(f"[error]: Failed to decode JSON from {PACKAGE_INDEX_FILE}.")
340
+ sys.exit(1)
341
+
342
+ # check if the local file data has valid fields
343
+ if 'available' not in localData:
344
+ print(f"[error]: Package index file '{PACKAGE_INDEX_FILE}' is "
345
+ f"missing 'available' field.")
346
+ sys.exit(1)
347
+
348
+ # store data
349
+ packageCache['available'] = localData['available']
350
+ # we'll check if this data is stale later and update if needed
351
+
352
+ # structure of the package index
353
+ localPackages = { # stores locally installed packages
354
+ 'system': {
355
+ 'lastupdated': -1.0,
356
+ 'sitebase': sys.prefix, # system base directory
357
+ 'packages': {}, # dict of installed packages and other info
358
+ },
359
+ 'user': {
360
+ 'lastupdated': -1.0,
361
+ 'sitebase': PYTHONUSERBASE, # user base directory
362
+ 'packages': {},
363
+ }
364
+ }
365
+
366
+ # get all local packages for the index
367
+ for pkgsite in ['system', 'user']:
368
+ pkgList = getInstalledPackages(where=pkgsite)
369
+ print(f'Found {len(pkgList)} packages in "{pkgsite}" site-packages '
370
+ f'location.')
371
+ localPackages[pkgsite]['packages'] = pkgList
372
+ localPackages[pkgsite]['lastupdated'] = time.time() # set updated time
373
+
374
+ packageCache['installed'] = localPackages # set installed packages
375
+
376
+ # update the plugin index
377
+ _fetchPluginIndex(fetch)
378
+
379
+ def _fetch(packageIndex):
380
+ """This function fetches and updates the local package index file with
381
+ data from a remote source.
382
+
383
+ """
384
+ url = packageCache['available']['remote'][packageIndex]['url']
385
+ # doctype = packageCache['available']['remote'][packageIndex]['doctype']
386
+
387
+ downloadStartTime = time.time()
388
+ print(f'Fetching "{packageIndex}" remote package index from URL: {url} ')
389
+ response = requests.get(url)
390
+ if response.status_code != 200:
391
+ print('')
392
+ print(f"[error]: Failed to fetch {url}: {response.status_code}")
393
+ return False
394
+ print(f'Completed fetching remote package index for "{packageIndex}" '
395
+ f'(took {round(time.time() - downloadStartTime, 4)} seconds)')
396
+
397
+ # parse the HTML content
398
+ print(f'Parsing downloaded "{packageIndex}" remote package index...')
399
+ parseStartTime = time.time()
400
+ soup = BeautifulSoup(response.text, 'html.parser')
401
+ packageCache['available']['remote'][packageIndex]['packages'].clear()
402
+ for a in soup.find_all('a', href=True):
403
+ # Check if the link is a package link
404
+ if re.search(r'/simple/[^/]+/', a['href']):
405
+ package_name = a['href'].split('/')[-2]
406
+ packageCache['available']['remote'][packageIndex]['packages'].append(package_name)
407
+
408
+ packageCache['available']['remote'][packageIndex]['lastupdated'] = time.time()
409
+
410
+ print(f'Completed parsing remote package index for "{packageIndex}" '
411
+ f'(took {round(time.time() - parseStartTime, 4)} seconds)')
412
+
413
+ return True
414
+
415
+ for remoteIndex in ['PyPI']:
416
+ lastUpdated = packageCache['available']['remote'][remoteIndex]['lastupdated']
417
+ isStale = lastUpdated < time.time() - _indexStaleAfter
418
+ if isStale or fetch:
419
+ print(f'[notice]: Remote package index "{remoteIndex}" is stale, updating...')
420
+ _fetch(packageIndex=remoteIndex)
421
+ else:
422
+ # index is not stale, use the cached data
423
+ print(f'[notice]: Remote package index "{remoteIndex}" is not stale, using locally cached data.')
424
+
425
+ totalPackages = len(
426
+ packageCache['available']['remote'][remoteIndex]['packages'])
427
+ print(f'Found {totalPackages} available packages in "{remoteIndex}" remote index.')
428
+
429
+ # write out the package index to a file
430
+ with open(PACKAGE_INDEX_FILE, 'w') as f:
431
+ print(f'Writing package index to {PACKAGE_INDEX_FILE}...')
432
+ try:
433
+ json.dump(packageCache, f, indent=4)
434
+ except Exception as e:
435
+ print(f"[error]: Failed to write package index to {PACKAGE_INDEX_FILE}: {e}")
436
+ sys.exit(1)
437
+
438
+ print(f'Finished updating local package index cache (took '
439
+ f'{round(time.time() - updateStartTime, 4)} seconds)')
440
+
441
+ freeLockFile() # free the lock file
442
+
443
+
444
+ def getPackageVersions(packageName):
445
+ """Fetches the package versions from the PyPI simple index page.
446
+
447
+ Parameters
448
+ ----------
449
+ packageName : str
450
+ The name of the package to fetch versions for.
451
+
452
+ """
453
+ url = 'https://pypi.org/simple/' + packageName + '/'
454
+ response = requests.get(url)
455
+ if response.status_code != 200:
456
+ raise Exception(f"Failed to fetch {url}: {response.status_code}")
457
+
458
+ # parse the HTML content
459
+ soup = BeautifulSoup(response.text, 'html.parser')
460
+ versions = set()
461
+ for a in soup.find_all('a', href=True):
462
+ match = re.search(rf'{packageName}-(\d+\.\d+\.\d+)', a['href'], re.IGNORECASE)
463
+ if match:
464
+ version = match.group(1)
465
+ versions.add(version)
466
+
467
+ versions = list(versions)
468
+ versions.sort(key=Version)
469
+
470
+ return versions
471
+
472
+
473
+ # ------------------------------------------------------------------------------
474
+ # Helper functions to call pip and parse output
475
+ #
476
+
477
+ def _parsePIPListOutput(output, ncols=2):
478
+ """Parse the output of the pip list command.
479
+
480
+ Parameters
481
+ ----------
482
+ output : str
483
+ The output of the pip list command.
484
+ ncols : int, optional
485
+ The number of columns in the output. Default is 2.
486
+
487
+ Returns
488
+ -------
489
+ dict
490
+ A dictionary of installed packages with their versions.
491
+
492
+ """
493
+ packages = {}
494
+ for line in output.splitlines():
495
+ if not line:
496
+ break
497
+ if line.startswith('Package'): # skip heading
498
+ continue
499
+ elif line.startswith('-'):
500
+ continue
501
+
502
+ # add the package to the dictionary
503
+ parts = line.split()
504
+ if len(parts) != ncols:
505
+ continue
506
+
507
+ packageName = parts[0]
508
+ versionInfo = parts[1:]
509
+ versionCols = len(versionInfo)
510
+
511
+ if versionCols == 1:
512
+ packageVersions = versionInfo[0] # simple case
513
+ else:
514
+ packageVersions = versionInfo
515
+
516
+ packages[packageName] = packageVersions
517
+
518
+ return packages
519
+
520
+
521
+ def _callPIP(cmd, userBase=None):
522
+ """Call the pip list command and return the output.
523
+
524
+ Parameters
525
+ ----------
526
+ cmd : list
527
+ The command to call pip with.
528
+ userBase : str, optional
529
+ User base directory. If not provided, the default user base directory
530
+ will be used.
531
+
532
+ Returns
533
+ -------
534
+ str
535
+ The output of the pip list command.
536
+
537
+ """
538
+ env = os.environ.copy()
539
+
540
+ try:
541
+ _exec = sys.executable
542
+ if userBase is None:
543
+ userBase = PYTHONUSERBASE
544
+
545
+ # set the user base directory in the environment
546
+ env['PYTHONUSERBASE'] = userBase
547
+
548
+ # call pip list command and return output
549
+ _cmd = [_exec, '-m', 'pip'] + cmd
550
+ output = subprocess.check_output(
551
+ _cmd,
552
+ stderr=subprocess.STDOUT,
553
+ env=env)
554
+ except subprocess.CalledProcessError as e:
555
+ raise Exception(f"Failed to call pip command: {e}")
556
+ except FileNotFoundError as e:
557
+ raise Exception(f"Failed to find pip executable: {e}")
558
+ except Exception as e:
559
+ raise Exception(f"Failed to call pip command: {e}")
560
+
561
+ return output.decode('utf-8')
562
+
563
+
564
+ # ------------------------------------------------------------------------------
565
+ # Functions to get installed and outdated packages
566
+ #
567
+
568
+ def getInstalledPackages(where='system', userBase=None):
569
+ """Get the installed packages in the local environment.
570
+
571
+ Parameters
572
+ ----------
573
+ where : str, optional
574
+ The type of installed packages to return. Can be 'system' or 'user'.
575
+ Default is 'system'.
576
+ userBase : str, optional
577
+ User base directory. If not provided, the default user base directory
578
+ will be used. This is only used if `which` is 'user'.
579
+
580
+ Returns
581
+ -------
582
+ dict
583
+ A dictionary of installed packages with their versions.
584
+
585
+ """
586
+ if where not in ['system', 'user']:
587
+ raise ValueError(
588
+ f"Invalid value for 'where': {where}. Must be 'system' or 'user'.")
589
+
590
+ cmd = ['list']
591
+ if where == 'user':
592
+ cmd.append('--user')
593
+
594
+ # use pip to get the installed packages
595
+ output = _callPIP(cmd, userBase=userBase)
596
+
597
+ # parse the output and return a dictionary of installed packages
598
+ packages = _parsePIPListOutput(output, ncols=2)
599
+
600
+ return packages
601
+
602
+
603
+ def getOutdatedPackages(which='system', userBase=None):
604
+ """Get the outdated packages in the local environment.
605
+
606
+ Parameters
607
+ ----------
608
+ which : str, optional
609
+ The type of installed packages to return. Can be 'system' or 'user'.
610
+ Default is 'system'.
611
+ userBase : str, optional
612
+ User base directory. If not provided, the default user base directory
613
+ will be used. This is only used if `which` is 'user'.
614
+
615
+ Returns
616
+ -------
617
+ dict
618
+ A dictionary of outdated packages with their versions.
619
+
620
+ """
621
+ if which not in ['system', 'user']:
622
+ raise ValueError(
623
+ f"Invalid value for 'which': {which}. Must be 'system' or 'user'.")
624
+
625
+ # use pip to get the outdated packages
626
+ cmd = ['list', '--outdated']
627
+ if which == 'user':
628
+ cmd.append('--user')
629
+
630
+ output = _callPIP(cmd, userBase=userBase)
631
+ packages = _parsePIPListOutput(output, ncols=4)
632
+
633
+ return packages
634
+
635
+
636
+ def getPluginPackages():
637
+ """Get a list of available PsychoPy plugins.
638
+ """
639
+ pass
640
+
641
+
642
+ # ------------------------------------------------------------------------------
643
+ # Functions to install, upgrade, and uninstall packages
644
+ #
645
+
646
+ def installPackage(packageName, where='user', forceReinstall=False,
647
+ noCacheDir=False, extraIndexURL=None, userBase=None):
648
+ """Install a package using pip.
649
+
650
+ Parameters
651
+ ----------
652
+ packageName : str
653
+ The name of the package to install.
654
+ version : str, optional
655
+ The version of the package to install. If not provided, the latest
656
+ version will be installed.
657
+ where : str, optional
658
+ Where to install the packages. Can be 'system' or 'user'. Default is
659
+ 'system'.
660
+ forceReinstall : bool, optional
661
+ Whether to force reinstall the package if it is already installed.
662
+ Default is False.
663
+ noCacheDir : bool, optional
664
+ Whether to disable the cache directory. Default is False.
665
+ extraIndexURL : str, optional
666
+ The URL of the package index to use. If not provided, the default
667
+ package index will be used.
668
+ userBase : str, optional
669
+ User base directory. If not provided, the default user base directory
670
+ will be used. This is only used if `where` is 'user'.
671
+
672
+ Examples
673
+ --------
674
+ Installing a package to the user base directory:
675
+
676
+ installPackage('numpy', where='user')
677
+
678
+ Installing a package with a specific version:
679
+
680
+ installPackage('numpy==1.21.0', where='user')
681
+
682
+ """
683
+ if where not in ['system', 'user']:
684
+ raise ValueError(
685
+ f"Invalid value for 'which': {where}. Must be 'system' or 'user'.")
686
+
687
+ cmd = ['install', packageName]
688
+
689
+ if where == 'user':
690
+ cmd.append('--user') # install to user base directory
691
+ if forceReinstall:
692
+ cmd.append('--force-reinstall')
693
+ if extraIndexURL:
694
+ cmd.append('--extra-index-url')
695
+ cmd.append(extraIndexURL)
696
+ if noCacheDir:
697
+ cmd.append('--no-cache-dir')
698
+
699
+ # use pip to install the package
700
+ print(f'Installing {packageName} to "{where}" site-packages...')
701
+ _ = _callPIP(cmd, userBase=userBase)
702
+ print(f'Completed installing {packageName} to "{where}" site-packages.')
703
+
704
+
705
+ def upgradePackage(packageName, strategy='eager', extraIndexURL=None, userBase=None):
706
+ """Upgrade an installed package to the latest version.
707
+
708
+ Parameters
709
+ ----------
710
+ packageName : str
711
+ The name of the package to upgrade.
712
+ extraIndexURL : str, optional
713
+ The URL of the package index to use. If not provided, the default
714
+ package index will be used.
715
+ userBase : str, optional
716
+ User base directory. If not provided, the default user base directory
717
+ will be used. This is only used if `which` is 'user'.
718
+
719
+ """
720
+ # use pip to upgrade the package
721
+ cmd = [
722
+ 'install',
723
+ '--upgrade',
724
+ packageName]
725
+
726
+ if extraIndexURL:
727
+ cmd.append('--extra-index-url')
728
+ cmd.append(extraIndexURL)
729
+ if strategy:
730
+ cmd.append('--upgrade-strategy')
731
+ cmd.append(strategy)
732
+
733
+ print('Upgrading package:', packageName)
734
+
735
+ _ = _callPIP(cmd, userBase=userBase)
736
+
737
+
738
+ def upgradeAllPackages(where='system', strategy='eager', userBase=None):
739
+ """Upgrade all installed packages to the latest version.
740
+
741
+ Parameters
742
+ ----------
743
+ where : str, optional
744
+ The type of installed packages to upgrade. Can be 'system' or 'user'.
745
+ Default is 'system'.
746
+ strategy : str, optional
747
+ The upgrade strategy to use. Can be 'eager' or 'only-if-needed'.
748
+ Default is 'eager'.
749
+ userBase : str, optional
750
+ User base directory. If not provided, the default user base directory
751
+ will be used. This is only used if `where` is 'user'.
752
+
753
+ """
754
+ if where not in ['system', 'user']:
755
+ raise ValueError(
756
+ f"Invalid value for 'where': {where}. Must be 'system' or 'user'.")
757
+
758
+ # use pip to upgrade all packages
759
+ cmd = ['install', '--upgrade']
760
+ if where == 'user':
761
+ cmd.append('--user')
762
+
763
+ # list all packages to upgrade
764
+ outdatedPackages = getOutdatedPackages(which=where, userBase=userBase)
765
+
766
+ if not outdatedPackages:
767
+ print('No packages to upgrade.')
768
+ return
769
+
770
+ print(f'Found {len(outdatedPackages)} packages to upgrade.')
771
+
772
+ # generate the list of packages to upgrade
773
+ packagesToUpgrade = []
774
+ for packageName, versionInfo in outdatedPackages.items():
775
+ print(f'Package "{packageName}" is outdated, marking for upgrade: {versionInfo[0]} -> {versionInfo[1]}')
776
+ packagesToUpgrade.append(packageName)
777
+
778
+ cmd += packagesToUpgrade
779
+
780
+ if strategy:
781
+ cmd.append('--upgrade-strategy')
782
+ cmd.append(strategy)
783
+
784
+ print(f'Upgrading {len(packagesToUpgrade)} packages in {where} site-packages...')
785
+ _ = _callPIP(cmd, userBase=userBase)
786
+
787
+ print('Completed upgrading packages.')
788
+
789
+
790
+ def uninstallPackage(packageName, userBase=None):
791
+ """Uninstall a package using pip.
792
+
793
+ Parameters
794
+ ----------
795
+ packageName : str
796
+ The name of the package to uninstall.
797
+ userBase : str, optional
798
+ User base directory. If not provided, the default user base directory
799
+ will be used. This is only used if `which` is 'user'.
800
+
801
+ """
802
+ # use pip to uninstall the package
803
+ cmd = ['uninstall', packageName, '-y'] # always use the -y flag to confirm
804
+ _ = _callPIP(cmd, userBase=userBase)
805
+
806
+
807
+ def getPackageInfo(packageName):
808
+ """Get the package info from the local environment.
809
+
810
+ Parameters
811
+ ----------
812
+ packageName : str
813
+ The name of the package to get info for.
814
+
815
+ Returns
816
+ -------
817
+ dict
818
+ A dictionary of package info.
819
+
820
+ """
821
+ from importlib import metadata
822
+
823
+ # get package metadata
824
+ try:
825
+ toReturn = {
826
+ 'installed_version': metadata.version(packageName),
827
+ 'available_versions': [],
828
+ 'metadata': {},
829
+ }
830
+
831
+ # get the package metadata
832
+ toReturn['available_versions'] = getPackageVersions(packageName)
833
+
834
+ pkgMetadata = metadata.metadata(packageName)
835
+ pkgMetadata = dict(pkgMetadata)
836
+ toReturn['metadata'] = pkgMetadata
837
+
838
+ return toReturn
839
+ except ImportError:
840
+ # package not found in local environment
841
+ raise ValueError(
842
+ f"Package '{packageName}' not found in local environment.")
843
+
844
+
845
+ # command-line interface
846
+
847
+ def main():
848
+ """Main function to run the package utility.
849
+ """
850
+ # use argparse
851
+ desc = f'PsychoPy package managment utility v{__version__} (Copyright 2025 - Open Science Tools Ltd.)'
852
+
853
+ parser = argparse.ArgumentParser(
854
+ description=desc,
855
+ prog='psychopy-pkgutil.py',
856
+ formatter_class=argparse.ArgumentDefaultsHelpFormatter)
857
+ parser.add_argument(
858
+ '--user-base', type=str, help='User base directory.')
859
+ parser.add_argument(
860
+ '--index-file', type=str, default=PACKAGE_INDEX_FILE,
861
+ help='Local package index cache file. Default is "psychopy_packages.json"')
862
+ parser.add_argument(
863
+ '--app-pref-dir', type=str, default=None,
864
+ help='PsychoPy app preferences directory.')
865
+ parser.add_argument(
866
+ '--verbose', action='store_true', help='Enable verbose output.')
867
+
868
+ # subcommands are 'update', 'list', 'install', 'uninstall', and 'upgrade'
869
+ subparsers = parser.add_subparsers(dest='command')
870
+ subparsers.required = True
871
+
872
+ # update command
873
+ update_parser = subparsers.add_parser('update', help='Update the package index.')
874
+ update_parser.add_argument(
875
+ '--fetch', action='store_true', help='Fetch a fresh package index.')
876
+ update_parser.add_argument(
877
+ '--stale-after', type=float, default=28.0,
878
+ help='Time in days before the package index is considered stale.')
879
+
880
+ # list command
881
+ list_parser = subparsers.add_parser('list', help='List installed packages.')
882
+ list_parser.add_argument(
883
+ '--outdated', action='store_true', help='List outdated packages.')
884
+ list_parser.add_argument(
885
+ '--json', action='store_true', help='Output in JSON format.')
886
+ list_parser.add_argument(
887
+ '--user', action='store_true', help='List user packages.')
888
+ list_parser.add_argument(
889
+ '--system', action='store_true', help='List system packages.')
890
+ list_parser.add_argument(
891
+ '--plugins', action='store_true', help='List available PsychoPy plugins.')
892
+ list_parser.add_argument(
893
+ '--all', action='store_true', help='List all packages.')
894
+
895
+ # install command
896
+ install_parser = subparsers.add_parser('install', help='Install a package.')
897
+ install_parser.add_argument(
898
+ 'package', type=str, help='The name of the package to install.')
899
+ install_parser.add_argument(
900
+ '--user', action='store_true', help='Install to user base directory.')
901
+ install_parser.add_argument(
902
+ '--force', action='store_true', help='Force reinstall the package.')
903
+ install_parser.add_argument(
904
+ '--no-cache', action='store_true', help='Disable the cache directory.')
905
+ install_parser.add_argument(
906
+ '--extra-index-url', type=str, help='The URL of the package index to use.')
907
+
908
+ # uninstall command
909
+ uninstall_parser = subparsers.add_parser('uninstall', help='Uninstall a package.')
910
+ uninstall_parser.add_argument(
911
+ 'package', type=str, help='The name of the package to uninstall.')
912
+
913
+ # upgrade command
914
+ upgrade_parser = subparsers.add_parser('upgrade', help='Upgrade a package.')
915
+ upgrade_parser.add_argument(
916
+ 'package', type=str, help='The name of the package to upgrade.')
917
+
918
+ # parse the arguments
919
+ args = parser.parse_args()
920
+
921
+ # configure paths if root config directory is specified
922
+ if args.app_pref_dir:
923
+ setUserBase(os.path.join(args.app_pref_dir, 'packages'))
924
+ setPackageIndexFilePath(
925
+ os.path.join(
926
+ args.app_pref_dir, 'cache', 'appCache', PACKAGE_INDEX_FILE))
927
+ else:
928
+ if args.user_base:
929
+ setUserBase(args.user_base)
930
+
931
+ if args.index_file:
932
+ setPackageIndexFilePath(args.index_file)
933
+
934
+ if args.command == 'update':
935
+ if args.stale_after:
936
+ setStaleTime(args.stale_after)
937
+ updatePackageIndex(fetch=args.fetch)
938
+ elif args.command == 'list':
939
+ if args.outdated:
940
+ packages = getOutdatedPackages()
941
+ else:
942
+ packages = getInstalledPackages()
943
+
944
+ for packageName, versionInfo in packages.items():
945
+ print(f"{packageName}: {versionInfo}")
946
+ elif args.command == 'install':
947
+ installPackage(
948
+ args.package,
949
+ where='user' if args.user else 'system',
950
+ forceReinstall=args.force,
951
+ noCacheDir=args.no_cache,
952
+ extraIndexURL=args.extra_index_url,
953
+ userBase=args.user_base)
954
+ elif args.command == 'uninstall':
955
+ uninstallPackage(args.package, userBase=args.user_base)
956
+ elif args.command == 'upgrade':
957
+ upgradePackage(
958
+ args.package,
959
+ extraIndexURL=args.extra_index_url,
960
+ userBase=args.user_base)
961
+ else:
962
+ parser.print_help()
963
+ sys.exit(1)
964
+
965
+
966
+ if __name__ == "__main__":
967
+ # setStaleTime(0.00001) # set the index to be stale after 24 hours
968
+ # updatePackageIndex(fetch=True) # update the package index
969
+ main()