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.
- psychopy/VERSION +1 -1
- psychopy/alerts/alertsCatalogue/4810.yaml +19 -0
- psychopy/alerts/alertsCatalogue/alertCategories.yaml +4 -0
- psychopy/alerts/alertsCatalogue/alertmsg.py +15 -1
- psychopy/alerts/alertsCatalogue/generateAlertmsg.py +2 -2
- psychopy/app/Resources/classic/add_many.png +0 -0
- psychopy/app/Resources/classic/add_many@2x.png +0 -0
- psychopy/app/Resources/classic/devices.png +0 -0
- psychopy/app/Resources/classic/devices@2x.png +0 -0
- psychopy/app/Resources/classic/photometer.png +0 -0
- psychopy/app/Resources/classic/photometer@2x.png +0 -0
- psychopy/app/Resources/dark/add_many.png +0 -0
- psychopy/app/Resources/dark/add_many@2x.png +0 -0
- psychopy/app/Resources/dark/devices.png +0 -0
- psychopy/app/Resources/dark/devices@2x.png +0 -0
- psychopy/app/Resources/dark/photometer.png +0 -0
- psychopy/app/Resources/dark/photometer@2x.png +0 -0
- psychopy/app/Resources/light/add_many.png +0 -0
- psychopy/app/Resources/light/add_many@2x.png +0 -0
- psychopy/app/Resources/light/devices.png +0 -0
- psychopy/app/Resources/light/devices@2x.png +0 -0
- psychopy/app/Resources/light/photometer.png +0 -0
- psychopy/app/Resources/light/photometer@2x.png +0 -0
- psychopy/app/_psychopyApp.py +35 -13
- psychopy/app/builder/builder.py +88 -35
- psychopy/app/builder/dialogs/__init__.py +69 -220
- psychopy/app/builder/dialogs/dlgsCode.py +29 -8
- psychopy/app/builder/dialogs/paramCtrls.py +1468 -904
- psychopy/app/builder/validators.py +25 -17
- psychopy/app/coder/coder.py +12 -1
- psychopy/app/coder/repl.py +5 -2
- psychopy/app/colorpicker/__init__.py +1 -1
- psychopy/app/deviceManager/__init__.py +1 -0
- psychopy/app/deviceManager/addDialog.py +218 -0
- psychopy/app/deviceManager/dialog.py +185 -0
- psychopy/app/deviceManager/panel.py +191 -0
- psychopy/app/deviceManager/utils.py +60 -0
- psychopy/app/idle.py +7 -0
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +12695 -10592
- psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/da_DK/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/de_DE/LC_MESSAGE/messages.po +11221 -9712
- psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/el_GR/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_NZ/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_US/LC_MESSAGE/messages.po +10195 -18
- psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_CO/LC_MESSAGE/messages.po +11917 -9101
- psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_ES/LC_MESSAGE/messages.po +11924 -9103
- psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_US/LC_MESSAGE/messages.po +11917 -9101
- psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/et_EE/LC_MESSAGE/messages.po +11084 -9569
- psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fa_IR/LC_MESSAGE/messages.po +11590 -5806
- psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fi_FI/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fr_FR/LC_MESSAGE/messages.po +11091 -9577
- psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/he_IL/LC_MESSAGE/messages.po +11072 -9549
- psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hi_IN/LC_MESSAGE/messages.po +11071 -9559
- psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hu_HU/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/it_IT/LC_MESSAGE/messages.po +11072 -9560
- psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ja_JP/LC_MESSAGE/messages.po +1485 -1137
- psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ko_KR/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ms_MY/LC_MESSAGE/messages.po +11463 -8757
- psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nl_NL/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nn_NO/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pl_PL/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pt_PT/LC_MESSAGE/messages.po +11288 -9434
- psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ro_RO/LC_MESSAGE/messages.po +10200 -25
- psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ru_RU/LC_MESSAGE/messages.po +10199 -24
- psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/sv_SE/LC_MESSAGE/messages.po +11441 -8747
- psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/tr_TR/LC_MESSAGE/messages.po +11069 -9545
- psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/messages.po +12085 -8268
- psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_TW/LC_MESSAGE/messages.po +11929 -8022
- psychopy/app/plugin_manager/dialog.py +12 -3
- psychopy/app/plugin_manager/packageIndex.py +303 -0
- psychopy/app/plugin_manager/packages.py +203 -63
- psychopy/app/plugin_manager/plugins.py +120 -240
- psychopy/app/preferencesDlg.py +6 -1
- psychopy/app/psychopyApp.py +16 -4
- psychopy/app/runner/runner.py +10 -2
- psychopy/app/runner/scriptProcess.py +8 -3
- psychopy/app/stdout/stdOutRich.py +11 -4
- psychopy/app/themes/icons.py +3 -0
- psychopy/app/utils.py +61 -0
- psychopy/colors.py +10 -5
- psychopy/data/experiment.py +133 -23
- psychopy/data/routine.py +12 -0
- psychopy/data/staircase.py +42 -20
- psychopy/data/trial.py +20 -12
- psychopy/data/utils.py +43 -3
- psychopy/demos/builder/Experiments/dragAndDrop/drag_and_drop.psyexp +22 -5
- psychopy/demos/builder/Experiments/dragAndDrop/stimuli/solutions.xlsx +0 -0
- psychopy/demos/builder/Experiments/stroopVoice/stroopVoice.psyexp +2 -12
- psychopy/demos/builder/Feature Demos/buttonBox/buttonBoxDemo.psyexp +3 -8
- psychopy/demos/builder/Feature Demos/movies/movie.psyexp +220 -0
- psychopy/demos/builder/Feature Demos/movies/readme.md +3 -0
- psychopy/demos/builder/Feature Demos/visualValidator/visualValidator.psyexp +1 -2
- psychopy/demos/builder/Hardware/camera/camera.psyexp +3 -16
- psychopy/demos/builder/Hardware/microphone/microphone.psyexp +3 -16
- psychopy/demos/coder/hardware/hdf5_extract.py +133 -0
- psychopy/event.py +20 -15
- psychopy/experiment/_experiment.py +86 -10
- psychopy/experiment/components/__init__.py +3 -10
- psychopy/experiment/components/_base.py +9 -20
- psychopy/experiment/components/button/__init__.py +1 -1
- psychopy/experiment/components/buttonBox/__init__.py +50 -54
- psychopy/experiment/components/camera/__init__.py +137 -359
- psychopy/experiment/components/keyboard/__init__.py +17 -24
- psychopy/experiment/components/microphone/__init__.py +61 -110
- psychopy/experiment/components/movie/__init__.py +2 -3
- psychopy/experiment/components/serialOut/__init__.py +192 -93
- psychopy/experiment/components/settings/__init__.py +45 -27
- psychopy/experiment/components/sound/__init__.py +82 -73
- psychopy/experiment/components/soundsensor/__init__.py +43 -80
- psychopy/experiment/devices.py +303 -0
- psychopy/experiment/exports.py +20 -18
- psychopy/experiment/flow.py +7 -0
- psychopy/experiment/loops.py +47 -29
- psychopy/experiment/monitor.py +74 -0
- psychopy/experiment/params.py +48 -10
- psychopy/experiment/plugins.py +28 -108
- psychopy/experiment/py2js_transpiler.py +1 -1
- psychopy/experiment/routines/__init__.py +1 -1
- psychopy/experiment/routines/_base.py +59 -24
- psychopy/experiment/routines/audioValidator/__init__.py +19 -155
- psychopy/experiment/routines/visualValidator/__init__.py +25 -25
- psychopy/hardware/__init__.py +20 -57
- psychopy/hardware/button.py +15 -2
- psychopy/hardware/camera/__init__.py +2237 -1394
- psychopy/hardware/joystick/__init__.py +1 -1
- psychopy/hardware/keyboard.py +5 -8
- psychopy/hardware/listener.py +4 -1
- psychopy/hardware/manager.py +75 -35
- psychopy/hardware/microphone.py +53 -7
- psychopy/hardware/monitor.py +144 -0
- psychopy/hardware/photometer/__init__.py +156 -117
- psychopy/hardware/serialdevice.py +16 -2
- psychopy/hardware/soundsensor.py +4 -1
- psychopy/iohub/devices/deviceConfigValidation.py +2 -1
- psychopy/iohub/devices/eyetracker/hw/gazepoint/__init__.py +2 -2
- psychopy/iohub/devices/eyetracker/hw/gazepoint/gp3/__init__.py +1 -0
- psychopy/iohub/devices/eyetracker/hw/gazepoint/gp3/eyetracker.py +10 -0
- psychopy/iohub/devices/keyboard/darwin.py +8 -5
- psychopy/iohub/util/__init__.py +7 -8
- psychopy/localization/generateTranslationTemplate.py +208 -116
- psychopy/localization/messages.pot +4305 -3502
- psychopy/monitors/MonitorCenter.py +174 -74
- psychopy/plugins/__init__.py +6 -4
- psychopy/preferences/devices.py +80 -0
- psychopy/preferences/generateHints.py +2 -1
- psychopy/preferences/preferences.py +35 -11
- psychopy/scripts/psychopy-pkgutil.py +969 -0
- psychopy/scripts/psyexpCompile.py +1 -1
- psychopy/session.py +34 -38
- psychopy/sound/__init__.py +6 -260
- psychopy/sound/audioclip.py +164 -0
- psychopy/sound/backend_ptb.py +8 -0
- psychopy/sound/backend_pygame.py +10 -0
- psychopy/sound/backend_pysound.py +9 -0
- psychopy/sound/backends/__init__.py +0 -0
- psychopy/sound/microphone.py +3 -0
- psychopy/sound/sound.py +58 -0
- psychopy/tests/data/correctScript/python/correctNoiseStimComponent.py +1 -1
- psychopy/tests/data/duplicateHeaders.csv +2 -0
- psychopy/tests/test_app/test_builder/test_BuilderFrame.py +22 -7
- psychopy/tests/test_app/test_builder/test_CompileFromBuilder.py +0 -2
- psychopy/tests/test_data/test_utils.py +5 -1
- psychopy/tests/test_experiment/test_components/test_ButtonBoxComponent.py +22 -2
- psychopy/tests/test_hardware/test_ports.py +0 -12
- psychopy/tests/test_tools/test_stringtools.py +1 -1
- psychopy/tools/attributetools.py +12 -5
- psychopy/tools/fontmanager.py +17 -14
- psychopy/tools/gltools.py +4 -2
- psychopy/tools/movietools.py +43 -2
- psychopy/tools/stringtools.py +33 -8
- psychopy/tools/versionchooser.py +1 -1
- psychopy/validation/audio.py +5 -1
- psychopy/validation/visual.py +5 -1
- psychopy/visual/basevisual.py +8 -7
- psychopy/visual/circle.py +2 -2
- psychopy/visual/helpers.py +3 -1
- psychopy/visual/image.py +29 -109
- psychopy/visual/movies/__init__.py +1800 -313
- psychopy/visual/polygon.py +4 -0
- psychopy/visual/shape.py +2 -2
- psychopy/visual/window.py +35 -12
- psychopy/voicekey/__init__.py +41 -669
- psychopy/voicekey/labjack_vks.py +7 -48
- psychopy/voicekey/parallel_vks.py +7 -42
- psychopy/voicekey/vk_tools.py +114 -263
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/METADATA +20 -13
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/RECORD +222 -190
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/WHEEL +1 -1
- psychopy/visual/movies/players/__init__.py +0 -62
- psychopy/visual/movies/players/ffpyplayer_player.py +0 -1401
- psychopy/voicekey/demo_vks.py +0 -12
- psychopy/voicekey/signal.py +0 -42
- {psychopy-2025.1.0.dist-info → psychopy-2025.2.1.dist-info}/entry_points.txt +0 -0
- {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()
|