psychopy 2024.2.1__py3-none-any.whl → 2024.2.4__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 (276) hide show
  1. psychopy/.DS_Store +0 -0
  2. psychopy/GIT_SHA +1 -1
  3. psychopy/VERSION +1 -1
  4. psychopy/__init__.py +10 -1
  5. psychopy/__init__.py.orig +65 -0
  6. psychopy/app/{locale/ar_001/.DS_Store → .DS_Store} +0 -0
  7. psychopy/app/Resources/.DS_Store +0 -0
  8. psychopy/app/_psychopyApp.py +11 -3
  9. psychopy/app/appData.spec +1 -1
  10. psychopy/app/builder/builder.py +1 -1
  11. psychopy/app/builder/builder.py.orig +3932 -0
  12. psychopy/app/builder/dialogs/__init__.py.orig +1679 -0
  13. psychopy/app/builder/dialogs/paramCtrls.py +1 -1
  14. psychopy/app/builder/dialogs/paramCtrls.py.orig +713 -0
  15. psychopy/app/colorpicker/__init__.py.orig +411 -0
  16. psychopy/app/cortex.log +0 -0
  17. psychopy/app/jobs.py +8 -1
  18. psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +2452 -1731
  19. psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN.mo +0 -0
  20. psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN.po +6127 -0
  21. psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN_allFlagged.mo +0 -0
  22. psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN_allFlagged.po +7366 -0
  23. psychopy/app/plugin_manager/dialog.py +9 -7
  24. psychopy/app/ribbon.py +2 -1
  25. psychopy/app/runner/runner.py +7 -5
  26. psychopy/clock.py +8 -4
  27. psychopy/core.py.orig +169 -0
  28. psychopy/demos/builder/Design Templates/randomisedBlocks/html/index.html +23 -0
  29. psychopy/demos/builder/Design Templates/randomisedBlocks/html/randomisedBlocks-legacy-browsers.js +423 -0
  30. psychopy/demos/builder/Design Templates/randomisedBlocks/html/randomisedBlocks.js +427 -0
  31. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/chooseBlock.xlsx +0 -0
  32. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/facesBlock.xlsx +0 -0
  33. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/housesBlock.xlsx +0 -0
  34. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face01.jpg +0 -0
  35. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face02.jpg +0 -0
  36. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face03.jpg +0 -0
  37. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house01.jpg +0 -0
  38. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house02.jpg +0 -0
  39. psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house03.jpg +0 -0
  40. psychopy/demos/builder/Design Templates/randomisedBlocks/randomisedBlocks.py +330 -0
  41. psychopy/demos/builder/Design Templates/randomisedBlocks/randomisedBlocks_lastrun.py +330 -0
  42. psychopy/demos/builder/Feature Demos/eyetracking/eyetracking.xml +298 -0
  43. psychopy/demos/builder/Feature Demos/eyetracking/eyetracking.xsd +120 -0
  44. psychopy/demos/builder/Tools/.DS_Store +0 -0
  45. psychopy/demos/builder/Tools/gammaCalibration/.DS_Store +0 -0
  46. psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.csv +38 -0
  47. psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.log +3418 -0
  48. psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.psydat +0 -0
  49. psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.csv +2 -0
  50. psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.log +15 -0
  51. psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.psydat +0 -0
  52. psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual.psyexp +323 -0
  53. psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual.py +562 -0
  54. psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual_lastrun.py +562 -0
  55. psychopy/demos/builder/Tools/gammaCalibration/questStairs.xlsx +0 -0
  56. psychopy/demos/builder/Tools/gammaCalibration/readme.md +0 -0
  57. psychopy/demos/builder/Tools/gammaCalibration/resources/low_contrast.png +0 -0
  58. psychopy/demos/builder/Tools/gammaCalibration/resources/make_2nd_order_tex.py +59 -0
  59. psychopy/demos/builder/Tools/gammaCalibration/resources/second_order_tex.png +0 -0
  60. psychopy/demos/coder/.DS_Store +0 -0
  61. psychopy/demos/coder/experiment control/info_gamma.pickle +0 -0
  62. psychopy/demos/coder/iohub/.iohpid +1 -0
  63. psychopy/demos/coder/iohub/eyetracking/.iohpid +1 -0
  64. psychopy/demos/coder/iohub/wintab/.DS_Store +0 -0
  65. psychopy/demos/coder/stimuli/.DS_Store +0 -0
  66. psychopy/demos/coder/stimuli/radialGratingContracting.py +29 -0
  67. psychopy/experiment/_experiment.py.orig +1032 -0
  68. psychopy/experiment/components/.DS_Store +0 -0
  69. psychopy/experiment/components/_base.py +13 -4
  70. psychopy/experiment/components/_base.py.orig +823 -0
  71. psychopy/experiment/components/form/.DS_Store +0 -0
  72. psychopy/experiment/components/microphone/__init__.py +10 -1
  73. psychopy/experiment/components/microphone/__init__.py.orig +490 -0
  74. psychopy/experiment/components/polygon/__init__.py +21 -22
  75. psychopy/experiment/components/settings/__init__.py +13 -14
  76. psychopy/experiment/components/settings/__init__.py.orig +1337 -0
  77. psychopy/experiment/components/textbox/__init__.py.orig +310 -0
  78. psychopy/experiment/components/webcam/.DS_Store +0 -0
  79. psychopy/experiment/components/webcam/light/.DS_Store +0 -0
  80. psychopy/experiment/flow.py +10 -8
  81. psychopy/experiment/loops.py.orig +829 -0
  82. psychopy/experiment/params.py +8 -3
  83. psychopy/experiment/params.py.orig +408 -0
  84. psychopy/experiment/routine.py.orig +503 -0
  85. psychopy/experiment/routines/_base.py +15 -6
  86. psychopy/experiment/routines/counterbalance/__init__.py +1 -0
  87. psychopy/gui/qtgui.py +14 -7
  88. psychopy/gui/util.py +10 -14
  89. psychopy/gui/wxgui.py +10 -4
  90. psychopy/hardware/.DS_Store +0 -0
  91. psychopy/hardware/brainproducts.py.orig +680 -0
  92. psychopy/hardware/iolab.py.orig +238 -0
  93. psychopy/hardware/manager.py +1 -1
  94. psychopy/hardware/photodiode.py +59 -27
  95. psychopy/hardware/serialport.py +51 -0
  96. psychopy/hardware/speaker.py +4 -4
  97. psychopy/iohub/datastore/__init__.py.orig +443 -0
  98. psychopy/iohub/datastore/util.py.orig +692 -0
  99. psychopy/iohub/devices/mouse/darwin.py.orig +427 -0
  100. psychopy/iohub/devices/mouse/linux2.py.orig +198 -0
  101. psychopy/preferences/.DS_Store +0 -0
  102. psychopy/projects/pavlovia.py +10 -3
  103. psychopy/projects/pavlovia.py.orig +1295 -0
  104. psychopy/sound/backend_ptb.py +22 -5
  105. psychopy/sound/transcribe.py +24 -4
  106. psychopy/tests/.DS_Store +0 -0
  107. psychopy/tests/data/.DS_Store +0 -0
  108. psychopy/tests/data/TestCircle_fill_local.png +0 -0
  109. psychopy/tests/data/__test.png +0 -0
  110. psychopy/tests/data/aperture1_normHexbackground_local.png +0 -0
  111. psychopy/tests/data/aperture1_norm_local.png +0 -0
  112. psychopy/tests/data/aperture2_normHexbackground_local.png +0 -0
  113. psychopy/tests/data/beatandrcos_height_local.png +0 -0
  114. psychopy/tests/data/beatandrcos_normAddBlend_local.png +0 -0
  115. psychopy/tests/data/beatandrcos_normHexbackground_local.png +0 -0
  116. psychopy/tests/data/beatandrcos_norm_local.png +0 -0
  117. psychopy/tests/data/beatandrcos_stencil_local.png +0 -0
  118. psychopy/tests/data/blend_add_height_local.png +0 -0
  119. psychopy/tests/data/blend_add_normAddBlend_local.png +0 -0
  120. psychopy/tests/data/blend_add_normHexbackground_local.png +0 -0
  121. psychopy/tests/data/blend_add_normNoShade_local.png +0 -0
  122. psychopy/tests/data/blend_add_norm_local.png +0 -0
  123. psychopy/tests/data/blend_add_stencil_local.png +0 -0
  124. psychopy/tests/data/bufferimg_gabor_height_local.png +0 -0
  125. psychopy/tests/data/bufferimg_gabor_normAddBlend_local.png +0 -0
  126. psychopy/tests/data/bufferimg_gabor_normHexbackground_local.png +0 -0
  127. psychopy/tests/data/bufferimg_gabor_normNoShade_local.png +0 -0
  128. psychopy/tests/data/bufferimg_gabor_norm_local.png +0 -0
  129. psychopy/tests/data/bufferimg_gabor_stencil_local.png +0 -0
  130. psychopy/tests/data/circleHex_height_local.png +0 -0
  131. psychopy/tests/data/circleHex_normAddBlend_local.png +0 -0
  132. psychopy/tests/data/circleHex_normHexbackground_local.png +0 -0
  133. psychopy/tests/data/circleHex_normNoShade_local.png +0 -0
  134. psychopy/tests/data/circleHex_norm_local.png +0 -0
  135. psychopy/tests/data/circleHex_stencil_local.png +0 -0
  136. psychopy/tests/data/color_comparison_local.png +0 -0
  137. psychopy/tests/data/corrFullRandom_local.csv +16 -0
  138. psychopy/tests/data/corrFullRandom_local.tsv +6 -0
  139. psychopy/tests/data/correctScript/.DS_Store +0 -0
  140. psychopy/tests/data/dots_height_local.png +0 -0
  141. psychopy/tests/data/dots_normAddBlend_local.png +0 -0
  142. psychopy/tests/data/dots_normHexbackground_local.png +0 -0
  143. psychopy/tests/data/dots_normNoShade_local.png +0 -0
  144. psychopy/tests/data/dots_norm_local.png +0 -0
  145. psychopy/tests/data/dots_stencil_local.png +0 -0
  146. psychopy/tests/data/elarray1_height_local.png +0 -0
  147. psychopy/tests/data/elarray1_normAddBlend_local.png +0 -0
  148. psychopy/tests/data/elarray1_normHexbackground_local.png +0 -0
  149. psychopy/tests/data/elarray1_norm_local.png +0 -0
  150. psychopy/tests/data/elarray1_stencil_local.png +0 -0
  151. psychopy/tests/data/envelopeandrcos_height_local.png +0 -0
  152. psychopy/tests/data/envelopeandrcos_normAddBlend_local.png +0 -0
  153. psychopy/tests/data/envelopeandrcos_normHexbackground_local.png +0 -0
  154. psychopy/tests/data/envelopeandrcos_norm_local.png +0 -0
  155. psychopy/tests/data/envelopeandrcos_stencil_local.png +0 -0
  156. psychopy/tests/data/envelopepowerandrcos_height_local.png +0 -0
  157. psychopy/tests/data/envelopepowerandrcos_normAddBlend_local.png +0 -0
  158. psychopy/tests/data/envelopepowerandrcos_normHexbackground_local.png +0 -0
  159. psychopy/tests/data/envelopepowerandrcos_norm_local.png +0 -0
  160. psychopy/tests/data/envelopepowerandrcos_stencil_local.png +0 -0
  161. psychopy/tests/data/gabor1_height_local.png +0 -0
  162. psychopy/tests/data/gabor1_normAddBlend_local.png +0 -0
  163. psychopy/tests/data/gabor1_normHexbackground_local.png +0 -0
  164. psychopy/tests/data/gabor1_normNoShade_local.png +0 -0
  165. psychopy/tests/data/gabor1_norm_local.png +0 -0
  166. psychopy/tests/data/gabor1_stencil_local.png +0 -0
  167. psychopy/tests/data/greyscale_normHexbackground_local.png +0 -0
  168. psychopy/tests/data/imageAndGauss_height_local.png +0 -0
  169. psychopy/tests/data/imageAndGauss_normAddBlend_local.png +0 -0
  170. psychopy/tests/data/imageAndGauss_normHexbackground_local.png +0 -0
  171. psychopy/tests/data/imageAndGauss_normNoShade_local.png +0 -0
  172. psychopy/tests/data/imageAndGauss_norm_local.png +0 -0
  173. psychopy/tests/data/imageAndGauss_stencil_local.png +0 -0
  174. psychopy/tests/data/movFrame1_stencil_local.png +0 -0
  175. psychopy/tests/data/noiseAndRcos_height_local.png +0 -0
  176. psychopy/tests/data/noiseAndRcos_normAddBlend_local.png +0 -0
  177. psychopy/tests/data/noiseAndRcos_normHexbackground_local.png +0 -0
  178. psychopy/tests/data/noiseAndRcos_normNoShade_local.png +0 -0
  179. psychopy/tests/data/noiseAndRcos_norm_local.png +0 -0
  180. psychopy/tests/data/noiseAndRcos_stencil_local.png +0 -0
  181. psychopy/tests/data/noiseFiltersAndRcos_height_local.png +0 -0
  182. psychopy/tests/data/noiseFiltersAndRcos_normAddBlend_local.png +0 -0
  183. psychopy/tests/data/noiseFiltersAndRcos_normHexbackground_local.png +0 -0
  184. psychopy/tests/data/noiseFiltersAndRcos_normNoShade_local.png +0 -0
  185. psychopy/tests/data/noiseFiltersAndRcos_norm_local.png +0 -0
  186. psychopy/tests/data/noiseFiltersAndRcos_stencil_local.png +0 -0
  187. psychopy/tests/data/numpyImage_height_local.png +0 -0
  188. psychopy/tests/data/numpyImage_normAddBlend_local.png +0 -0
  189. psychopy/tests/data/numpyImage_normHexbackground_local.png +0 -0
  190. psychopy/tests/data/numpyImage_normNoShade_local.png +0 -0
  191. psychopy/tests/data/numpyImage_norm_local.png +0 -0
  192. psychopy/tests/data/numpyImage_stencil_local.png +0 -0
  193. psychopy/tests/data/shape2_1_normAddBlend_local.png +0 -0
  194. psychopy/tests/data/shape2_1_normHexbackground_local.png +0 -0
  195. psychopy/tests/data/shape2_1_normNoShade_local.png +0 -0
  196. psychopy/tests/data/shape2_1_norm_local.png +0 -0
  197. psychopy/tests/data/shape2_1_stencil_local.png +0 -0
  198. psychopy/tests/data/testLoopsBlocks.psyexp_local.py +328 -0
  199. psychopy/tests/data/text1_height_local.png +0 -0
  200. psychopy/tests/data/text1_normAddBlend_local.png +0 -0
  201. psychopy/tests/data/text1_normHexbackground_local.png +0 -0
  202. psychopy/tests/data/text1_norm_local.png +0 -0
  203. psychopy/tests/data/text1_stencil_local.png +0 -0
  204. psychopy/tests/data/text2_height.png +0 -0
  205. psychopy/tests/data/text2_normAddBlend.png +0 -0
  206. psychopy/tests/data/text2_normHexbackground.png +0 -0
  207. psychopy/tests/data/text2_stencil.png +0 -0
  208. psychopy/tests/data/wedge1_height_local.png +0 -0
  209. psychopy/tests/data/wedge1_normAddBlend_local.png +0 -0
  210. psychopy/tests/data/wedge1_normHexbackground_local.png +0 -0
  211. psychopy/tests/data/wedge1_normNoShade_local.png +0 -0
  212. psychopy/tests/data/wedge1_norm_local.png +0 -0
  213. psychopy/tests/data/wedge1_stencil_local.png +0 -0
  214. psychopy/tests/test_app/.DS_Store +0 -0
  215. psychopy/tests/test_app/test_builder/.DS_Store +0 -0
  216. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.csv +9 -0
  217. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.log +177 -0
  218. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.psydat +0 -0
  219. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.xlsx +0 -0
  220. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.csv +9 -0
  221. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.log +168 -0
  222. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.psydat +0 -0
  223. psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.xlsx +0 -0
  224. psychopy/tests/test_data/.DS_Store +0 -0
  225. psychopy/tests/test_hardware/test_CRS_BitsSharp.py.orig +68 -0
  226. psychopy/tests/test_tools/test_arraytools.py +112 -0
  227. psychopy/tests/test_visual/test_image.py.orig +219 -0
  228. psychopy/tools/arraytools.py +47 -0
  229. psychopy/tools/versionchooser.py +1 -1
  230. psychopy/visual/backends/pygletbackend.py +26 -8
  231. psychopy/visual/basevisual.py.orig +1723 -0
  232. psychopy/visual/form.py.orig +1181 -0
  233. psychopy/visual/text.py.orig +752 -0
  234. psychopy/visual/textbox2/textbox2.py.orig +1315 -0
  235. psychopy/visual/window.py +13 -5
  236. psychopy/visual/windowwarp.py.orig +463 -0
  237. {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/METADATA +9 -9
  238. {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/RECORD +244 -78
  239. {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/WHEEL +1 -1
  240. {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/entry_points.txt +2 -0
  241. psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
  242. psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
  243. psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
  244. psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
  245. psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
  246. psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
  247. psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
  248. psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
  249. psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
  250. psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
  251. psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
  252. psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
  253. psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
  254. psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
  255. psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
  256. psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
  257. psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
  258. psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
  259. psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
  260. psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
  261. psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
  262. psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
  263. psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
  264. psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
  265. psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
  266. psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
  267. psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
  268. psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
  269. psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
  270. psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
  271. psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
  272. psychopy-2024.2.1.dist-info/licenses/AUTHORS.md +0 -138
  273. /psychopy/{app/locale/ar_001/LC_MESSAGE → demos/builder}/.DS_Store +0 -0
  274. /psychopy/{app/locale/es_ES/LC_MESSAGE → demos/builder/Experiments}/.DS_Store +0 -0
  275. /psychopy/{visual → demos/builder/Tools/gammaCalibration/data}/.DS_Store +0 -0
  276. {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1295 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+
4
+ # Part of the PsychoPy library
5
+ # Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2021 Open Science Tools Ltd.
6
+ # Distributed under the terms of the GNU General Public License (GPL).
7
+
8
+ """Helper functions in PsychoPy for interacting with Pavlovia.org
9
+ """
10
+ import glob
11
+ import json
12
+ import pathlib
13
+ import os
14
+ import re
15
+ import time
16
+ import subprocess
17
+ import traceback
18
+
19
+ import pandas
20
+ from pkg_resources import parse_version
21
+
22
+ from psychopy import logging, prefs, exceptions
23
+ from psychopy.tools.filetools import DictStorage, KnownProjects
24
+ from psychopy import app
25
+ from psychopy.localization import _translate
26
+ import wx
27
+
28
+ from ..tools.apptools import SortTerm
29
+
30
+ try:
31
+ import git # must import psychopy constants before this (custom git path)
32
+ haveGit = True
33
+ except ImportError:
34
+ haveGit = False
35
+
36
+ import requests
37
+ import gitlab
38
+ import gitlab.v4.objects
39
+
40
+ # for authentication
41
+ from . import sshkeys
42
+ from uuid import uuid4
43
+
44
+ from .gitignore import gitIgnoreText
45
+
46
+ from urllib import parse
47
+ urlencode = parse.quote
48
+
49
+ # TODO: test what happens if we have a network initially but lose it
50
+ # TODO: test what happens if we have a network but pavlovia times out
51
+
52
+ pavloviaPrefsDir = os.path.join(prefs.paths['userPrefsDir'], 'pavlovia')
53
+ rootURL = "https://gitlab.pavlovia.org"
54
+ client_id = '4bb79f0356a566cd7b49e3130c714d9140f1d3de4ff27c7583fb34fbfac604e0'
55
+ scopes = []
56
+ redirect_url = 'https://gitlab.pavlovia.org/'
57
+
58
+ knownUsers = DictStorage(
59
+ filename=os.path.join(pavloviaPrefsDir, 'users.json'))
60
+
61
+ # knownProjects is a dict stored by id ("namespace/name")
62
+ knownProjects = KnownProjects(
63
+ filename=os.path.join(pavloviaPrefsDir, 'projects.json'))
64
+ # knownProjects stores the gitlab id to check if it's the same exact project
65
+ # We add to the knownProjects when project.local is set (ie when we have a
66
+ # known local location for the project)
67
+
68
+ permissions = { # for ref see https://docs.gitlab.com/ee/user/permissions.html
69
+ 'guest': 10,
70
+ 'reporter': 20,
71
+ 'developer': 30, # (can push to non-protected branches)
72
+ 'maintainer': 30,
73
+ 'owner': 50}
74
+
75
+ MISSING_REMOTE = -1
76
+ OK = 1
77
+
78
+
79
+ def getAuthURL():
80
+ state = str(uuid4()) # create a private "state" based on uuid
81
+ auth_url = ('https://gitlab.pavlovia.org/oauth/authorize?client_id={}'
82
+ '&redirect_uri={}&response_type=token&state={}'
83
+ .format(client_id, redirect_url, state))
84
+ return auth_url, state
85
+
86
+
87
+ def login(tokenOrUsername, rememberMe=True):
88
+ """Sets the current user by means of a token
89
+
90
+ Parameters
91
+ ----------
92
+ token
93
+ """
94
+ currentSession = getCurrentSession()
95
+ if not currentSession:
96
+ raise requests.exceptions.ConnectionError("Failed to connect to Pavlovia.org. No network?")
97
+ # would be nice here to test whether this is a token or username
98
+ logging.debug('pavloviaTokensCurrently: {}'.format(knownUsers))
99
+ if tokenOrUsername in knownUsers:
100
+ token = knownUsers[tokenOrUsername] # username so fetch token
101
+ else:
102
+ token = tokenOrUsername
103
+ # it might still be a dict that *contains* the token
104
+ if type(token) == dict and 'token' in token:
105
+ token = token['token']
106
+
107
+ # try actually logging in with token
108
+ currentSession.setToken(token)
109
+ if currentSession.user is not None:
110
+ user = currentSession.user
111
+ prefs.appData['projects']['pavloviaUser'] = user['username']
112
+
113
+
114
+ def logout():
115
+ """Log the current user out of pavlovia.
116
+
117
+ NB This function does not delete the cookie from the wx mini-browser
118
+ if that has been set. Use pavlovia_ui for that.
119
+
120
+ - set the user for the currentSession to None
121
+ - save the appData so that the user is blank
122
+ """
123
+ # create a new currentSession with no auth token
124
+ global _existingSession
125
+ _existingSession.user = None
126
+ # set appData to None
127
+ prefs.appData['projects']['pavloviaUser'] = None
128
+ prefs.saveAppData()
129
+ for frameWeakref in app.openFrames:
130
+ frame = frameWeakref()
131
+ if hasattr(frame, 'setUser'):
132
+ frame.setUser(None)
133
+
134
+
135
+ class User(dict):
136
+ """Class to combine what we know about the user locally and on gitlab
137
+
138
+ (from previous logins and from the current session)"""
139
+
140
+ <<<<<<< HEAD
141
+ def __init__(self, id, rememberMe=True):
142
+ # Get info from Pavlovia
143
+ if isinstance(id, (float, int, str)):
144
+ # If given a number or string, treat it as a user ID / username
145
+ self.info = requests.get("https://pavlovia.org/api/v2/designers/" + str(id)).json()['designer']
146
+ elif isinstance(id, dict) and 'gitlabId' in id:
147
+ # If given a dict from Pavlovia rather than an ID, store it rather than requesting again
148
+ self.info = id
149
+ else:
150
+ raise TypeError(f"ID must be either an integer representing the user's numeric ID or a string "
151
+ f"representing their username, not `{id}`.")
152
+ # Store own ID
153
+ self.id = int(self.info['gitlabId'])
154
+ # Get user object from GitLab
155
+ self.user = self.session.gitlab.users.get(self.id)
156
+ # Add user email (mostly redundant but necessary for saving)
157
+ self.user.email = self.info['email']
158
+ # Init dict
159
+ dict.__init__(self, self.info)
160
+ # Update local
161
+ =======
162
+ def __init__(self, localData=None, gitlabData=None, rememberMe=True):
163
+ currentSession = getCurrentSession()
164
+ if localData is None:
165
+ self.data = {}
166
+ else:
167
+ self.data = localData
168
+ self.gitlabData = gitlabData
169
+ # try looking for local data
170
+ if gitlabData and not localData:
171
+ if gitlabData.username in knownUsers:
172
+ self.data = knownUsers[gitlabData.username]
173
+
174
+ # then try again to populate fields
175
+ if gitlabData and not localData:
176
+ self.data['username'] = gitlabData.username
177
+ self.data['token'] = currentSession.getToken()
178
+ self.avatar = gitlabData.attributes['avatar_url']
179
+ elif 'avatar' in localData:
180
+ self.avatar = localData['avatar']
181
+ elif gitlabData:
182
+ self.avatar = gitlabData.attributes['avatar_url']
183
+
184
+ # check and/or create SSH keys
185
+ # sshIdPath = os.path.join(prefs.paths['userPrefsDir'],
186
+ # "ssh", self.username)
187
+ # if os.path.isfile(sshIdPath):
188
+ # self.publicSSH = sshkeys.getPublicKey(sshIdPath + ".pub")
189
+ # else:
190
+ # self.publicSSH = sshkeys.saveKeyPair(sshIdPath,
191
+ # comment=gitlabData.email)
192
+ # # convert bytes to unicode if needed
193
+ # if type(self.publicSSH) == bytes:
194
+ # self.publicSSH = self.publicSSH.decode('utf-8')
195
+ # push that key to gitlab.pavlovia if possible/needed
196
+ # if gitlabData:
197
+ # keys = gitlabData.keys.list()
198
+ # keyName = '{}@{}'.format(
199
+ # self.username, socket.gethostname().strip(".local"))
200
+ # remoteKey = None
201
+ # for thisKey in keys:
202
+ # if thisKey.title == keyName:
203
+ # remoteKey = thisKey
204
+ # break
205
+ # if not remoteKey:
206
+ # remoteKey = gitlabData.keys.create({'title': keyName,
207
+ # 'key': self.publicSSH})
208
+ >>>>>>> release
209
+ if rememberMe:
210
+ self.saveLocal()
211
+
212
+ def __getitem__(self, key):
213
+ # Get either from self or project.attributes
214
+ try:
215
+ value = dict.__getitem__(self, key)
216
+ except KeyError:
217
+ value = self.user.attributes[key]
218
+
219
+ return value
220
+
221
+ def __setitem__(self, key, value):
222
+ dict.__setitem__(self, key, value)
223
+ self.user.__setattr__(key, value)
224
+
225
+ def __str__(self):
226
+ return "pavlovia.User <{}>".format(self['username'])
227
+
228
+ def __eq__(self, other):
229
+ if isinstance(other, self.__class__):
230
+ # Compare gitlab ID for two User objects
231
+ return int(self['id']) == int(other['id'])
232
+ elif isinstance(other, (int, float)) or (isinstance(other, str) and other.isnumeric()):
233
+ # Compare gitlab ID for an int or int-like
234
+ return int(self['id']) == int(other)
235
+ elif isinstance(other, str):
236
+ # Compare username for a string
237
+ return self['username'] == other
238
+
239
+ @property
240
+ def session(self):
241
+ # Cache session if not cached
242
+ if not hasattr(self, "_session"):
243
+ self._session = getCurrentSession()
244
+ # Return cached session
245
+ return self._session
246
+
247
+ def saveLocal(self):
248
+ """Saves the data on the current user in the pavlovia/users json file"""
249
+ knownUsers[self['username']] = self.user.attributes
250
+ knownUsers[self['username']]['token'] = self.session.getToken()
251
+ knownUsers.save()
252
+
253
+ def save(self):
254
+ self.user.save()
255
+
256
+
257
+ class PavloviaSession:
258
+ """A class to track a session with the server.
259
+
260
+ The session will store a token, which can then be used to authenticate
261
+ for project read/write access
262
+ """
263
+
264
+ def __init__(self, token=None, remember_me=True):
265
+ """Create a session to send requests with the pavlovia server
266
+
267
+ Provide either username and password for authentication with a new
268
+ token, or provide a token from a previous session, or nothing for an
269
+ anonymous user
270
+ """
271
+ self.username = None
272
+ self.userID = None # populate when token property is set
273
+ self.userFullName = None
274
+ self.remember_me = remember_me
275
+ self.authenticated = False
276
+ self.setToken(token)
277
+ logging.debug("PavloviaLoggedIn")
278
+
279
+ @property
280
+ def currentProject(self):
281
+ if hasattr(self, "_currentProject"):
282
+ return self._currentProject
283
+
284
+ @currentProject.setter
285
+ def currentProject(self, value):
286
+ self._currentProject = PavloviaProject(value)
287
+
288
+ def getOauthSuffix(self, prefix=""):
289
+ if self.getToken():
290
+ return f"{prefix}oauthToken={self.getToken()}"
291
+ else:
292
+ return ""
293
+
294
+ def createProject(self, name, description="", tags=(), visibility='public',
295
+ localRoot='', namespace=''):
296
+ """Returns a PavloviaProject object (derived from a gitlab.project)
297
+
298
+ Parameters
299
+ ----------
300
+ name
301
+ description
302
+ tags
303
+ visibility
304
+ local
305
+
306
+ Returns
307
+ -------
308
+ a PavloviaProject object
309
+
310
+ """
311
+ if not self.user:
312
+ raise exceptions.NoUserError("Tried to create project with no user logged in")
313
+ # NB gitlab also supports "internal" (public to registered users)
314
+ if type(visibility) == bool and visibility:
315
+ visibility = 'public'
316
+ elif type(visibility) == bool and not visibility:
317
+ visibility = 'private'
318
+
319
+ projDict = {}
320
+ projDict['name'] = name
321
+ projDict['description'] = description
322
+ projDict['issues_enabled'] = True
323
+ projDict['visibility'] = visibility
324
+ projDict['wiki_enabled'] = True
325
+ if namespace and namespace != self.username:
326
+ namespaceRaw = self.getNamespace(namespace)
327
+ if namespaceRaw:
328
+ projDict['namespace_id'] = namespaceRaw.id
329
+ else:
330
+ raise ValueError("PavloviaSession.createProject was given a "
331
+ "namespace that couldn't be found on gitlab.")
332
+ # Create project on GitLab
333
+ try:
334
+ gitlabProj = self.gitlab.projects.create(projDict)
335
+ except gitlab.exceptions.GitlabCreateError as e:
336
+ if 'has already been taken' in str(e.error_message):
337
+ # wx.MessageDialog(self, message=_translate(f"Project `{namespace}/{name}` already exists, please choose another name."), style=wx.ICON_WARNING)
338
+ raise gitlab.exceptions.GitlabCreateError(f"Project `{self.username}/{name}` already exists, please choose another name.")
339
+ else:
340
+ raise e
341
+
342
+ # Create pavlovia project object
343
+ pavProject = PavloviaProject(gitlabProj.get_id(), localRoot=localRoot)
344
+ return pavProject
345
+
346
+ def getProject(self, id):
347
+ """Gets a Pavlovia project from an ID number or namespace/name
348
+
349
+ Parameters
350
+ ----------
351
+ id a numerical
352
+
353
+ Returns
354
+ -------
355
+ pavlovia.PavloviaProject or None
356
+
357
+ """
358
+ if id:
359
+ return PavloviaProject(id)
360
+ else:
361
+ return None
362
+
363
+ def findProjects(self, search_str='', tags="psychopy"):
364
+ """
365
+ Parameters
366
+ ----------
367
+ search_str : str
368
+ The string to search for in the title of the project
369
+ tags : str
370
+ Comma-separated string containing tags
371
+
372
+ Returns
373
+ -------
374
+ A list of OSFProject objects
375
+
376
+ """
377
+ rawProjs = self.gitlab.projects.list(
378
+ search=search_str,
379
+ as_list=False) # iterator not list for auto-pagination
380
+ projs = [PavloviaProject(proj) for proj in rawProjs if proj.id]
381
+ return projs
382
+
383
+ def listUserGroups(self, namesOnly=True):
384
+ gps = self.gitlab.groups.list(member=True)
385
+ if namesOnly:
386
+ gps = [this.name for this in gps]
387
+ return gps
388
+
389
+ def findUserProjects(self, searchStr=''):
390
+ """Finds all readable projects of a given user_id
391
+ (None for current user)
392
+ """
393
+ try:
394
+ own = self.gitlab.projects.list(owned=True, search=searchStr)
395
+ except Exception as e:
396
+ print(e)
397
+ own = self.gitlab.projects.list(owned=True, search=searchStr)
398
+ group = self.gitlab.projects.list(owned=False, membership=True,
399
+ search=searchStr)
400
+ projs = []
401
+ projIDs = []
402
+ for proj in own + group:
403
+ if proj.id not in projIDs and proj.id not in projs:
404
+ projs.append(PavloviaProject(proj))
405
+ projIDs.append(proj.id)
406
+ return projs
407
+
408
+ def findUsers(self, search_str):
409
+ """Find user IDs whose name matches a given search string
410
+ """
411
+ return self.gitlab.users
412
+
413
+ def getToken(self):
414
+ """The authorisation token for the current logged in user
415
+ """
416
+ return self.__dict__['token']
417
+
418
+ def setToken(self, token):
419
+ """Set the token for this session and check that it works for auth
420
+ """
421
+ self.__dict__['token'] = token
422
+ self.startSession(token)
423
+
424
+ def getNamespace(self, namespace):
425
+ """Returns a namespace object for the given name if an exact match is
426
+ found
427
+ """
428
+ spaces = self.gitlab.namespaces.list(search=namespace)
429
+ # might be more than one, with
430
+ for thisSpace in spaces:
431
+ if thisSpace.path == namespace:
432
+ return thisSpace
433
+
434
+ def startSession(self, token):
435
+ """Start a gitlab session as best we can
436
+ (if no token then start an empty session)"""
437
+ if token:
438
+ if len(token) < 64:
439
+ raise ValueError(
440
+ "Trying to login with token {} which is shorter "
441
+ "than expected length ({} not 64) for gitlab token"
442
+ .format(repr(token), len(token)))
443
+ if parse_version(gitlab.__version__) > parse_version("1.4"):
444
+ self.gitlab = gitlab.Gitlab(rootURL, oauth_token=token, timeout=3, per_page=100)
445
+ else:
446
+ self.gitlab = gitlab.Gitlab(rootURL, oauth_token=token, timeout=3)
447
+ self.gitlab.auth()
448
+ self.username = self.gitlab.user.username
449
+ self.userID = self.gitlab.user.id # populate when token property is set
450
+ self.userFullName = self.gitlab.user.name
451
+ self.authenticated = True
452
+ else:
453
+ if parse_version(gitlab.__version__) > parse_version("1.4"):
454
+ self.gitlab = gitlab.Gitlab(rootURL, timeout=3, per_page=100)
455
+ else:
456
+ self.gitlab = gitlab.Gitlab(rootURL, timeout=3)
457
+
458
+ @property
459
+ def user(self):
460
+ if not hasattr(self, "_user") or self._user is None:
461
+ if not hasattr(self.gitlab, "user") or self.gitlab.user.username is None:
462
+ return None
463
+ self._user = User(self.gitlab.user.username)
464
+ return self._user
465
+
466
+ @user.setter
467
+ def user(self, value):
468
+ if isinstance(value, User) or value is None:
469
+ self._user = value
470
+ else:
471
+ self._user = User(value)
472
+
473
+
474
+ class PavloviaSearch(pandas.DataFrame):
475
+ # List all possible sort terms
476
+ sortTerms = [
477
+ SortTerm("nbStars", dLabel=_translate("Most stars"), aLabel=_translate("Least stars"), ascending=False),
478
+ SortTerm("nbForks", dLabel=_translate("Most forks"), aLabel=_translate("Least forks"), ascending=False),
479
+ SortTerm("updateDate", dLabel=_translate("Last edited"), aLabel=_translate("Longest since edited"), ascending=False),
480
+ SortTerm("creationDate", dLabel=_translate("Last created"), aLabel=_translate("First created"), ascending=False),
481
+ SortTerm("name", dLabel=_translate("Name (Z-A)"), aLabel=_translate("Name (A-Z)"), ascending=True),
482
+ SortTerm("pathWithNamespace", dLabel=_translate("Author (Z-A)"), aLabel=_translate("Author (A-Z)"), ascending=True),
483
+ ]
484
+
485
+ class FilterTerm(dict):
486
+ # Map filter menu items to project columns
487
+ filterMap = {
488
+ "Author": "designer",
489
+ "Status": "status",
490
+ "Platform": "platform",
491
+ "Visibility": "visibility",
492
+ "Tags": "tags",
493
+ }
494
+
495
+ def __str__(self):
496
+ # Start off with blank str
497
+ terms = ""
498
+ # Iterate through values
499
+ for key, value in self.items():
500
+ # Ensure value is iterable and mutable
501
+ if not isinstance(value, (list, tuple)):
502
+ value = [value]
503
+ value = list(value)
504
+ # Ensure each sub-value is a string
505
+ for i in range(len(value)):
506
+ value[i] = str(value[i])
507
+ # Skip empty terms
508
+ if len(value) == 0:
509
+ continue
510
+ # Alias keys
511
+ if key in self.filterMap:
512
+ key = self.filterMap[key]
513
+ # Add this term
514
+ terms += f"&{key}={','.join(value)}"
515
+ return terms
516
+
517
+ def __bool__(self):
518
+ return any(self.values())
519
+
520
+ def __init__(self, term, sortBy=None, filterBy=None, mine=False):
521
+ session = getCurrentSession()
522
+ # Replace default filter
523
+ if filterBy is None:
524
+ filterBy = {}
525
+ # Ensure filter is a FilterTerm
526
+ filterBy = self.FilterTerm(filterBy)
527
+ # Do search
528
+ try:
529
+ if term or filterBy or mine:
530
+ data = requests.get(f"https://pavlovia.org/api/v2/experiments?search={term}{filterBy}{session.getOauthSuffix('&')}",
531
+ timeout=2).json()
532
+ else:
533
+ # Display demos for blank search
534
+ data = requests.get("https://pavlovia.org/api/v2/designers/5/experiments",
535
+ timeout=5).json()
536
+ except requests.exceptions.ReadTimeout:
537
+ msg = "Could not connect to Pavlovia server. Please check that you are connected to the internet. If you are connected, then the Pavlovia servers may be down. You can check their status here: https://pavlovia.org/status"
538
+ raise ConnectionError(msg)
539
+ # Construct dataframe
540
+ pandas.DataFrame.__init__(self, data=data['experiments'])
541
+ # Apply me mode
542
+ if mine:
543
+ self.drop(self.loc[
544
+ # self['creatorId'] != session.userID # Created by me
545
+ (self['userIds'].explode() != session.userID).groupby(level=0).any() # Editable by me
546
+ ].index, inplace=True)
547
+ # Do any requested sorting
548
+ if sortBy is not None:
549
+ self.sort_values(sortBy)
550
+
551
+ def sort_values(self, by, inplace=True, ignore_index=True, **kwargs):
552
+ if isinstance(by, (str, int)):
553
+ by = [str(by)]
554
+ # Add mapped and selected menu items to sort keys list
555
+ sortKeys = []
556
+ ascending = []
557
+ for item in by:
558
+ if item.value in self.columns:
559
+ sortKeys.append(item.value)
560
+ ascending.append(item.ascending)
561
+ # Add pavlovia score as final sort option
562
+ sortKeys.append("pavloviaScore")
563
+ ascending += [False]
564
+ # Do actual sorting
565
+ if sortKeys:
566
+ pandas.DataFrame.sort_values(self, sortKeys,
567
+ inplace=inplace, ascending=ascending, ignore_index=ignore_index,
568
+ **kwargs)
569
+
570
+
571
+ class PavloviaProject(dict):
572
+ """A Pavlovia project, with name, url etc
573
+
574
+ .pavlovia will point to a gitlab project on gitlab.pavlovia.org
575
+ - None if the session couldn't be opened
576
+ - False if the session is open but the repo isn't there (deleted?)
577
+ .repo will will be a local gitpython repo
578
+ .localRoot is the path to the root of the repo
579
+ .id is the namespace/name (e.g. peircej/stroop)
580
+ .idNumber is gitlab numeric id
581
+ .title
582
+ .tags
583
+ .group is technically the namespace. Get the owner from .attributes['owner']
584
+ .localRoot is the path to the local root
585
+ """
586
+
587
+ def __init__(self, id, localRoot=None):
588
+ if not isinstance(id, int):
589
+ # If given a dict from Pavlovia rather than an ID, store it rather than requesting again
590
+ self.info = dict(id)
591
+ else:
592
+ # If given an ID, get Pavlovia info (for just created projects this can take a while, so allow 2s leeway)
593
+ start = time.time()
594
+ self.info = None
595
+ while self.info is None and time.time() - start < 5:
596
+ self.info = requests.get(f"https://pavlovia.org/api/v2/experiments/{id}{self.session.getOauthSuffix('?')}").json()['experiment']
597
+ if self.info is None:
598
+ raise LookupError(f"Could not find project with id `{id}` on Pavlovia")
599
+ self._newRemote = False # False can also indicate 'unknown'
600
+ # Store own id
601
+ self.id = int(self.info['gitlabId'])
602
+ # Init dict
603
+ dict.__init__(self, self.project.attributes)
604
+ # Convert datetime
605
+ dtRegex = re.compile("\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d(.\d\d\d)?")
606
+ for key in self.info:
607
+ if dtRegex.match(str(self.info[key])):
608
+ self.info[key] = pandas.to_datetime(self.info[key], format="%Y-%m-%d %H:%M:%S.%f")
609
+ # Set local root
610
+ if localRoot is not None:
611
+ self.localRoot = localRoot
612
+
613
+ def __getitem__(self, key):
614
+ # Get either from self or project.attributes
615
+ try:
616
+ value = dict.__getitem__(self, key)
617
+ except KeyError:
618
+ value = self.project.attributes[key]
619
+ # Transform datetimes
620
+ dtRegex = re.compile("\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d(.\d\d\d)?\w?")
621
+ if dtRegex.match(str(value)):
622
+ value = pandas.to_datetime(value, format="%Y-%m-%d %H:%M:%S.%f")
623
+
624
+ return value
625
+
626
+ def __setitem__(self, key, value):
627
+ dict.__setitem__(self, key, value)
628
+ self.project.__setattr__(key, value)
629
+
630
+ def refresh(self):
631
+ # Update Pavlovia info
632
+ self.info = requests.get(f"https://pavlovia.org/api/v2/experiments/{self.id}{self.session.getOauthSuffix('?')}").json()['experiment']
633
+ # Convert datetime
634
+ dtRegex = re.compile("\d\d\d\d-\d\d-\d\d \d\d:\d\d:\d\d(.\d\d\d)?")
635
+ for key in self.info:
636
+ if dtRegex.match(str(self.info[key])):
637
+ self.info[key] = pandas.to_datetime(self.info[key], format="%Y-%m-%d %H:%M:%S.%f")
638
+ # Update base dict
639
+ self.update(self.project.attributes)
640
+
641
+ @property
642
+ def session(self):
643
+ # If previous value is cached, return it
644
+ if hasattr(self, "_session"):
645
+ return self._session
646
+ # Get and cache current session
647
+ self._session = getCurrentSession()
648
+ return self._session
649
+
650
+ @property
651
+ def project(self):
652
+ # If previous value is cached, return it
653
+ if hasattr(self, "_project"):
654
+ return self._project
655
+ # Get and cache gitlab project
656
+ try:
657
+ self._project = self.session.gitlab.projects.get(self.id)
658
+ return self._project
659
+ except gitlab.exceptions.GitlabGetError as e:
660
+ raise LookupError(f"Could not find GitLab project with id {self.id}.")
661
+
662
+ @property
663
+ def editable(self):
664
+ """
665
+ Whether or not the project is editable by the current user
666
+ """
667
+ # If previous value is cached, return it
668
+ if hasattr(self, "_editable") and self._lastEditableCheckUser == self.session.user:
669
+ return self._editable
670
+ # Otherwise, figure it out
671
+ if self.session.user:
672
+ # Get current user id
673
+ _id = self.session.user['id']
674
+ # Get gitlab project users
675
+ _users = self.project.users.list()
676
+ # Return whether or not current user in in project users
677
+ self._editable = _id in [user.id for user in _users]
678
+ else:
679
+ # If there's no user, they can't edit, so return False
680
+ self._editable = False
681
+ # Store user when last checked
682
+ self._lastEditableCheckUser = self.session.user
683
+
684
+ return self._editable
685
+
686
+ @property
687
+ def owned(self):
688
+ """
689
+ Whether or not the project is owned by the current user
690
+ """
691
+ if bool(self.session.user):
692
+ # If there is a user, return True if they're the owner of this project
693
+ return self['path_with_namespace'].split('/')[0] == self.session.user['username']
694
+ else:
695
+ # If there isn't a user, then they can't own this project, so return False
696
+ return False
697
+
698
+ @property
699
+ def starred(self):
700
+ """
701
+ Star/unstar the project, or view starred status
702
+ """
703
+ # If previous value is cached, return it
704
+ if hasattr(self, "_starred"):
705
+ return self._starred
706
+ # Otherwise, return whether this project is in the list of starred projects
707
+ self._starred = bool(self.session.gitlab.projects.list(starred=True, search=str(self.id)))
708
+ return self._starred
709
+
710
+ @starred.setter
711
+ def starred(self, value):
712
+ # Enforce bool
713
+ value = bool(value)
714
+ # Store value
715
+ self._starred = value
716
+ # Set on gitlab
717
+ if value:
718
+ self.project.star()
719
+ else:
720
+ self.project.unstar()
721
+ # Get info from Pavlovia again, as star count will have changed
722
+ self.info = requests.get("https://pavlovia.org/api/v2/experiments/" + str(self.id)).json()['experiment']
723
+
724
+ @property
725
+ def localRoot(self):
726
+ if self.project.path_with_namespace in knownProjects:
727
+ # If project has known local store, return its root
728
+ return knownProjects[self.project.path_with_namespace]['localRoot']
729
+ else:
730
+ # Otherwise, return blank
731
+ return ""
732
+
733
+ @localRoot.setter
734
+ def localRoot(self, value):
735
+ if self.project.path_with_namespace in knownProjects:
736
+ # If project has known local store, update its root
737
+ knownProjects[self.project.path_with_namespace]['localRoot'] = value
738
+ knownProjects.save()
739
+ else:
740
+ # If project has no known local store, create one
741
+ knownProjects[self.project.path_with_namespace] = {
742
+ 'id': self['path_with_namespace'],
743
+ 'idNumber': self.id,
744
+ 'localRoot': value,
745
+ 'remoteHTTPS': f"https://gitlab.pavlovia.org/{self['path_with_namespace']}.git",
746
+ 'remoteSSH': f"git@gitlab.pavlovia.org:{self['path_with_namespace']}.git"
747
+ }
748
+ knownProjects.save()
749
+
750
+ def sync(self, infoStream=None):
751
+ """Performs a pull-and-push operation on the remote
752
+
753
+ Will check for a local folder and whether that is already (in) a repo.
754
+ If we have a local folder and it is not a git project already then
755
+ this function will also clone the remote to that local folder
756
+
757
+ Optional params infoStream is needed if you
758
+ want to update a sync window/panel
759
+ """
760
+ # Error catch local root
761
+ if not self.localRoot:
762
+ raise gitlab.GitlabGetError("Can't sync project without a local root.")
763
+ # Reset local repo so it checks again (rather than erroring if it's been deleted without an app restart)
764
+ self._repo = None
765
+ # Jot down start time
766
+ t0 = time.time()
767
+ # If first commit, do initial push
768
+ if not bool(self['default_branch']):
769
+ self.firstPush(infoStream=infoStream)
770
+ # Pull and push
771
+ self.pull(infoStream)
772
+ self.push(infoStream)
773
+ # Write updates
774
+ t1 = time.time()
775
+ msg = ("Successful sync at: {}, took {:.3f}s"
776
+ .format(time.strftime("%H:%M:%S", time.localtime()), t1 - t0))
777
+ logging.info(msg)
778
+ if infoStream:
779
+ infoStream.write("\n" + msg)
780
+ time.sleep(0.5)
781
+ # Refresh info
782
+ self.refresh()
783
+
784
+ return 1
785
+
786
+ def pull(self, infoStream=None):
787
+ """Pull from remote to local copy of the repository
788
+
789
+ Parameters
790
+ ----------
791
+ infoStream
792
+
793
+ Returns
794
+ -------
795
+ 1 if successful
796
+ -1 if project is deleted on remote
797
+ """
798
+ if infoStream:
799
+ infoStream.write("\nPulling changes from remote...")
800
+ if self.repo is None:
801
+ self.cloneRepo(infoStream)
802
+ try:
803
+ info = self.repo.git.pull(self.project.http_url_to_repo, 'master')
804
+ if infoStream:
805
+ infoStream.write("\n{}".format(info))
806
+ except git.exc.GitCommandError as e:
807
+ if ("The project you were looking for could not be found" in
808
+ traceback.format_exc()):
809
+ # pointing to a project at pavlovia but it doesn't exist
810
+ logging.warning("Project not found on gitlab.pavlovia.org")
811
+ return MISSING_REMOTE
812
+ else:
813
+ raise e
814
+
815
+ logging.debug('pull complete: {}'.format(self.project.http_url_to_repo))
816
+ if infoStream:
817
+ infoStream.write("\ndone")
818
+ return 1
819
+
820
+ def push(self, infoStream=None):
821
+ """Push to remote from local copy of the repository
822
+
823
+ Parameters
824
+ ----------
825
+ infoStream
826
+
827
+ Returns
828
+ -------
829
+ 1 if successful
830
+ -1 if project deleted on remote
831
+ """
832
+ if infoStream:
833
+ infoStream.write("\nPushing changes from remote...")
834
+ try:
835
+ info = self.repo.git.push(self.remoteWithToken, 'master')
836
+ if infoStream:
837
+ infoStream.write("\n{}".format(info))
838
+ except git.exc.GitCommandError as e:
839
+ if ("The project you were looking for could not be found" in
840
+ traceback.format_exc()):
841
+ # pointing to a project at pavlovia but it doesn't exist
842
+ logging.warning("Project not found on gitlab.pavlovia.org")
843
+ return MISSING_REMOTE
844
+ else:
845
+ raise e
846
+
847
+ logging.debug('push complete: {}'.format(self.project.http_url_to_repo))
848
+ if infoStream:
849
+ infoStream.write("done")
850
+ return 1
851
+
852
+ @property
853
+ def repo(self):
854
+ """Will always try to return a valid local git repo
855
+
856
+ Will try to clone if local is empty and remote is not"""
857
+ # If there's no local root, we can't find the repo
858
+ if not self.localRoot:
859
+ raise gitlab.GitlabGetError("Cannot fetch a PavloviaProject until we have chosen a local folder.")
860
+ # If repo is cached, return it
861
+ if hasattr(self, "_repo") and self._repo:
862
+ return self._repo
863
+
864
+ # Get git root
865
+ gitRoot = getGitRoot(self.localRoot)
866
+
867
+ if gitRoot is None:
868
+ self.newRepo()
869
+ elif gitRoot not in [self.localRoot, str(pathlib.Path(self.localRoot).absolute())]:
870
+ # this indicates that the requested root is inside another repo
871
+ raise AttributeError("The requested local path for project\n\t{}\n"
872
+ "sits inside another folder, which git will "
873
+ "not permit. You might like to set the "
874
+ "project local folder to be \n\t{}"
875
+ .format(repr(self.localRoot), repr(gitRoot)))
876
+ else:
877
+ # If there's a git root, return the associated repo
878
+ self._repo = git.Repo(gitRoot)
879
+
880
+ self.writeGitIgnore()
881
+
882
+ return self._repo
883
+
884
+ @repo.setter
885
+ def repo(self, value):
886
+ self._repo = value
887
+
888
+ @property
889
+ def remoteWithToken(self):
890
+ """The remote for git sync using an oauth token (always as a bytes obj)
891
+ """
892
+ return f"https://oauth2:{self.session.token}@gitlab.pavlovia.org/{self['path_with_namespace']}"
893
+
894
+ def writeGitIgnore(self):
895
+ """Check that a .gitignore file exists and add it if not"""
896
+ gitIgnorePath = os.path.join(self.localRoot, '.gitignore')
897
+ if not os.path.exists(gitIgnorePath):
898
+ with open(gitIgnorePath, 'w') as f:
899
+ f.write(gitIgnoreText)
900
+
901
+ def newRepo(self, infoStream=None):
902
+ """Will either git.init and git.push or git.clone depending on state
903
+ of local files.
904
+
905
+ Use newRemote if we know that the remote has only just been created
906
+ and is empty
907
+ """
908
+ localFiles = glob.glob(os.path.join(self.localRoot, "*"))
909
+ # glob doesn't match hidden files by default so search for them
910
+ localFiles.extend(glob.glob(os.path.join(self.localRoot, ".*")))
911
+
912
+ # there's no project at all so create one
913
+ if not self.localRoot:
914
+ raise AttributeError("Cannot fetch a PavloviaProject until we have "
915
+ "chosen a local folder.")
916
+ if not os.path.exists(self.localRoot):
917
+ os.makedirs(self.localRoot)
918
+
919
+ # check if the remote repo is empty (if so then to init/push)
920
+ if self.project:
921
+ try:
922
+ self.project.repository_tree()
923
+ bareRemote = False
924
+ except gitlab.GitlabGetError as e:
925
+ if "Tree Not Found" in str(e):
926
+ bareRemote = True
927
+ else:
928
+ bareRemote = False
929
+
930
+ # if remote is new (or existed but is bare) then init and push
931
+ if localFiles and (self._newRemote or bareRemote): # existing folder
932
+ repo = git.Repo.init(self.localRoot)
933
+ self.configGitLocal() # sets user.email and user.name
934
+ # add origin remote and master branch (but no push)
935
+ self.repo.create_remote('origin', url=self['remoteHTTPS'])
936
+ self.repo.git.checkout(b="master")
937
+ self.writeGitIgnore()
938
+ self.stageFiles(['.gitignore'])
939
+ self.commit('Create repository (including .gitignore)')
940
+ self._newRemote = True
941
+ else:
942
+ # no files locally so safe to try and clone from remote
943
+ repo = self.cloneRepo(infoStream=infoStream)
944
+ # TODO: add the further case where there are remote AND local files!
945
+
946
+ return repo
947
+
948
+ def firstPush(self, infoStream):
949
+ if infoStream:
950
+ infoStream.write("\nPushing to Pavlovia for the first time...")
951
+ info = self.repo.git.push('-u', self.remoteWithToken, 'master')
952
+ if infoStream:
953
+ infoStream.write("\n{}".format(info))
954
+ infoStream.write("\nSuccess!".format(info))
955
+
956
+ def cloneRepo(self, infoStream=None):
957
+ """Gets the git.Repo object for this project, creating one if needed
958
+
959
+ Will check for a local folder and whether that is already (in) a repo.
960
+ If we have a local folder and it is not a git project already then
961
+ this function will also clone the remote to that local folder
962
+
963
+ Parameters
964
+ ----------
965
+ infoStream
966
+
967
+ Returns
968
+ -------
969
+ git.Repo object
970
+
971
+ Raises
972
+ ------
973
+ AttributeError if the local project is inside a git repo
974
+
975
+ """
976
+ if not self.localRoot:
977
+ raise AttributeError("Cannot fetch a PavloviaProject until we have "
978
+ "chosen a local folder.")
979
+
980
+ if infoStream:
981
+ infoStream.SetValue("Cloning from remote...")
982
+ self.repo = git.Repo.clone_from(
983
+ self.remoteWithToken,
984
+ self.localRoot,
985
+ )
986
+ # now change the remote to be the standard (without password token)
987
+ self.repo.remotes.origin.set_url(self.project.http_url_to_repo)
988
+
989
+ self._lastKnownSync = time.time()
990
+ self._newRemote = False
991
+
992
+ return self.repo
993
+
994
+ def configGitLocal(self):
995
+ """Set the local repo to have the correct name and email for user
996
+
997
+ Returns
998
+ -------
999
+ None
1000
+ """
1001
+ localConfig = self.repo.git.config(l=True, local=True) # list local
1002
+ if self.session.user['email'] in localConfig:
1003
+ return # we already have it set up so can return
1004
+ # set the local config
1005
+ with self.repo.config_writer() as config:
1006
+ config.set_value("user", "email", self.session.user['email'])
1007
+ config.set_value("user", "name", self.session.user['name'])
1008
+
1009
+ def fork(self, to=None):
1010
+ # Sub in current user if none given
1011
+ if to is None:
1012
+ to = self.session.user['username']
1013
+ # Do fork
1014
+ try:
1015
+ glProj = self.project.forks.create({'namespace': to})
1016
+ except gitlab.GitlabCreateError:
1017
+ raise gitlab.GitlabCreateError(f"Project {self.session.user['username']}/{self['name']} already exists!")
1018
+ # Get new project
1019
+ proj = PavloviaProject(glProj.id)
1020
+ # Return new project
1021
+ return proj
1022
+
1023
+ def getChanges(self):
1024
+ """Find all the not-yet-committed changes in the repository"""
1025
+ changeDict = {}
1026
+ changeDict['untracked'] = self.repo.untracked_files
1027
+ changeDict['changed'] = []
1028
+ changeDict['deleted'] = []
1029
+ changeDict['renamed'] = []
1030
+ for this in self.repo.index.diff(None):
1031
+ # change type, identifying possible ways a blob can have changed
1032
+ # A = Added
1033
+ # D = Deleted
1034
+ # R = Renamed
1035
+ # M = Modified
1036
+ # T = Changed in the type
1037
+ if this.change_type == 'D':
1038
+ changeDict['deleted'].append(this.b_path)
1039
+ elif this.change_type == 'R': # only if git rename had been called?
1040
+ changeDict['renamed'].append((this.rename_from, this.rename_to))
1041
+ elif this.change_type == 'M':
1042
+ changeDict['changed'].append(this.b_path)
1043
+ elif this.change_type == 'U':
1044
+ changeDict['changed'].append(this.b_path)
1045
+ else:
1046
+ raise ValueError("Found an unexpected change_type '{}' in gitpython Diff".format(this.change_type))
1047
+ changeList = []
1048
+ for categ in changeDict:
1049
+ changeList.extend(changeDict[categ])
1050
+ return changeDict, changeList
1051
+
1052
+ def stageFiles(self, files=None, infoStream=None):
1053
+ """Adds changed files to the stage (index) ready for commit.
1054
+
1055
+ The files is a list and can include new/changed/deleted
1056
+
1057
+ If files=None this is like `git add -u` (all files added/deleted)
1058
+ """
1059
+ if files:
1060
+ if type(files) not in (list, tuple):
1061
+ raise TypeError(
1062
+ 'The `files` provided to PavloviaProject.stageFiles '
1063
+ 'should be a list not a {}'.format(type(files)))
1064
+ try:
1065
+ for thisFile in files:
1066
+ self.repo.git.add(thisFile)
1067
+ except git.exc.GitCommandError:
1068
+ if infoStream:
1069
+ infoStream.SetValue(traceback.format_exc())
1070
+ else:
1071
+ diffsDict, diffsList = self.getChanges()
1072
+ if diffsDict['untracked']:
1073
+ self.repo.git.add(diffsDict['untracked'])
1074
+ if diffsDict['deleted']:
1075
+ self.repo.git.add(diffsDict['deleted'])
1076
+ if diffsDict['changed']:
1077
+ self.repo.git.add(diffsDict['changed'])
1078
+
1079
+ def getStagedFiles(self):
1080
+ """Retrieves the files that are already staged ready for commit"""
1081
+ return self.repo.index.diff("HEAD")
1082
+
1083
+ def unstageFiles(self, files):
1084
+ """Removes changed files from the stage (index) preventing their commit.
1085
+ The files in question can be new/changed/deleted
1086
+ """
1087
+ self.repo.git.reset('--', files)
1088
+
1089
+ def commit(self, message):
1090
+ """Commits the staged changes"""
1091
+ self.repo.git.commit('-m', message)
1092
+ time.sleep(0.1)
1093
+ # then get a new copy of the repo
1094
+ self.repo = git.Repo(self.localRoot)
1095
+
1096
+ def save(self):
1097
+ """Saves the metadata to gitlab.pavlovia.org"""
1098
+ self.project.save()
1099
+ # note that saving info locally about known projects is done
1100
+ # by the knownProjects DictStorage class
1101
+
1102
+ @property
1103
+ def pavloviaStatus(self):
1104
+ return self.__dict__['pavloviaStatus']
1105
+
1106
+ @pavloviaStatus.setter
1107
+ def pavloviaStatus(self, newStatus):
1108
+ url = 'https://pavlovia.org/server?command=update_project'
1109
+ data = {'projectId': self.id, 'projectStatus': 'ACTIVATED'}
1110
+ resp = requests.put(url, data)
1111
+ if resp.status_code == 200:
1112
+ self.__dict__['pavloviaStatus'] = newStatus
1113
+ else:
1114
+ print(resp)
1115
+
1116
+ @property
1117
+ def permissions(self):
1118
+ """This returns the user's overall permissions for the project as int.
1119
+ Unlike the project.attribute['permissions'] which returns a dict of
1120
+ permissions for group/project which is sometimes also a dict and
1121
+ sometimes an int!
1122
+
1123
+ returns
1124
+ ------------
1125
+ permissions as an int:
1126
+ -1 = probably not logged in?
1127
+ None = logged in but no permissions
1128
+
1129
+ """
1130
+ if not getCurrentSession().user:
1131
+ return -1
1132
+ if 'permissions' in self.attributes:
1133
+ # collect perms for both group and individual access
1134
+ allPerms = []
1135
+ permsDict = self.attributes['permissions']
1136
+ if 'project_access' in permsDict:
1137
+ allPerms.append(permsDict['project_access'])
1138
+ if 'group_access' in permsDict:
1139
+ allPerms.append(permsDict['group_access'])
1140
+ # make ints and find the max permission of the project/group
1141
+ permInts = []
1142
+ for thisPerm in allPerms:
1143
+ # check if deeper in dict
1144
+ if type(thisPerm) == dict:
1145
+ thisPerm = thisPerm['access_level']
1146
+ # we have a single value (but might still be None)
1147
+ if thisPerm is not None:
1148
+ permInts.append(thisPerm)
1149
+ if permInts:
1150
+ perms = max(permInts)
1151
+ else:
1152
+ perms = None
1153
+ elif hasattr(self, 'project_access') and self.project_access:
1154
+ perms = self.project_access
1155
+ if type(perms) == dict:
1156
+ perms = perms['access_level']
1157
+ else:
1158
+ perms = None # not sure if this ever occurs when logged in
1159
+ return perms
1160
+
1161
+
1162
+ def getGitRoot(p):
1163
+ """Return None or the root path of the repository"""
1164
+ if not haveGit:
1165
+ raise exceptions.DependencyError(
1166
+ "gitpython and a git installation required for getGitRoot()")
1167
+
1168
+ p = pathlib.Path(p).absolute()
1169
+ if not p.is_dir():
1170
+ p = p.parent # given a file instead of folder?
1171
+
1172
+ proc = subprocess.Popen('git branch --show-current',
1173
+ stdout=subprocess.PIPE,
1174
+ stderr=subprocess.PIPE,
1175
+ cwd=str(p), shell=True,
1176
+ universal_newlines=True) # newlines forces stdout to unicode
1177
+ stdout, stderr = proc.communicate()
1178
+ if 'not a git repository' in (stdout + stderr):
1179
+ return None
1180
+ else:
1181
+ # this should have been possible with git rev-parse --top-level
1182
+ # but that sometimes returns a virtual symlink that is not the normal folder name
1183
+ # e.g. some other mount point?
1184
+ selfAndParents = [p] + list(p.parents)
1185
+ for thisPath in selfAndParents:
1186
+ if list(thisPath.glob('.git')):
1187
+ return str(thisPath) # convert Path back to str
1188
+
1189
+
1190
+ def getNameWithNamespace(p):
1191
+ """
1192
+ Return None or the root path of the repository
1193
+ """
1194
+ # Work out cwd
1195
+ if not haveGit:
1196
+ raise exceptions.DependencyError(
1197
+ "gitpython and a git installation required for getGitRoot()")
1198
+
1199
+ p = pathlib.Path(p).absolute()
1200
+ if not p.is_dir():
1201
+ p = p.parent # given a file instead of folder?
1202
+ # Open git process
1203
+ proc = subprocess.Popen('git config --get remote.origin.url',
1204
+ stdout=subprocess.PIPE,
1205
+ stderr=subprocess.PIPE,
1206
+ cwd=str(p), shell=True,
1207
+ universal_newlines=True) # newlines forces stdout to unicode
1208
+ stdout, stderr = proc.communicate()
1209
+ # Find a gitlab url in the response
1210
+ url = re.match("https:\/\/gitlab\.pavlovia\.org\/\w*\/\w*\.git", stdout)
1211
+ if url:
1212
+ # Get contents of url from response
1213
+ url = url.string[url.pos:url.endpos]
1214
+ # Get namespace/name string from url
1215
+ path = url
1216
+ path = re.sub("\.git[.\n]*", "", path)
1217
+ path = re.sub("[.\n]*https:\/\/gitlab\.pavlovia\.org\/", "", path)
1218
+ return path
1219
+ else:
1220
+ return None
1221
+
1222
+
1223
+
1224
+ def getProject(filename):
1225
+ """Will try to find (locally synced) pavlovia Project for the filename
1226
+ """
1227
+ # Check that we have Git
1228
+ if not haveGit:
1229
+ raise exceptions.DependencyError(
1230
+ "gitpython and a git installation required for getProject()")
1231
+ # Get git root
1232
+ gitRoot = getGitRoot(filename)
1233
+ # Get name with namespace
1234
+ path = getNameWithNamespace(filename)
1235
+ # Get session
1236
+ session = getCurrentSession()
1237
+ # If already found, return
1238
+ if (knownProjects is not None) and (path in knownProjects) and ('idNumber' in knownProjects[path]):
1239
+ try:
1240
+ return PavloviaProject(knownProjects[path]['idNumber'])
1241
+ except LookupError as err:
1242
+ # If project not found, print warning and return None
1243
+ logging.warn(str(err))
1244
+ return None
1245
+ elif gitRoot:
1246
+ # Existing repo but not in our knownProjects. Investigate
1247
+ logging.info("Investigating repo at {}".format(gitRoot))
1248
+ localRepo = git.Repo(gitRoot)
1249
+ for remote in localRepo.remotes:
1250
+ for url in remote.urls:
1251
+ if "gitlab.pavlovia.org" in url:
1252
+ # Get Namespace/Name from standard style url
1253
+ nameSearch = re.search(r"(?<=https:\/\/gitlab\.pavlovia\.org\/).*\/.*(?=\.git)", url)
1254
+ elif "git@gitlab.pavlovia.org:" in url:
1255
+ # Get Namespace/Name from @ stye url
1256
+ nameSearch = re.search(r"(?<=git@gitlab\.pavlovia\.org:).*\/.*(?=\.git)", url)
1257
+ else:
1258
+ # Attempt to get Namespace/Name from unhandled style
1259
+ nameSearch = re.search(r"[\w\-]*\\[\w\-]*\.git", url)
1260
+ if nameSearch is not None:
1261
+ name = nameSearch.group(0)
1262
+ project = session.gitlab.projects.get(name)
1263
+ return PavloviaProject(project.id)
1264
+
1265
+
1266
+ global _existingSession
1267
+ _existingSession = None
1268
+
1269
+
1270
+ # create an instance of that
1271
+ def getCurrentSession():
1272
+ """Returns the current Pavlovia session, creating one if not yet present
1273
+
1274
+ Returns
1275
+ -------
1276
+
1277
+ """
1278
+ global _existingSession
1279
+ if _existingSession:
1280
+ return _existingSession
1281
+ else:
1282
+ _existingSession = PavloviaSession()
1283
+ return _existingSession
1284
+
1285
+
1286
+ def refreshSession():
1287
+ """Restarts the session with the same user logged in"""
1288
+ global _existingSession
1289
+ if _existingSession and _existingSession.getToken():
1290
+ _existingSession = PavloviaSession(
1291
+ token=_existingSession.getToken()
1292
+ )
1293
+ else:
1294
+ _existingSession = PavloviaSession()
1295
+ return _existingSession