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.
- psychopy/.DS_Store +0 -0
- psychopy/GIT_SHA +1 -1
- psychopy/VERSION +1 -1
- psychopy/__init__.py +10 -1
- psychopy/__init__.py.orig +65 -0
- psychopy/app/{locale/ar_001/.DS_Store → .DS_Store} +0 -0
- psychopy/app/Resources/.DS_Store +0 -0
- psychopy/app/_psychopyApp.py +11 -3
- psychopy/app/appData.spec +1 -1
- psychopy/app/builder/builder.py +1 -1
- psychopy/app/builder/builder.py.orig +3932 -0
- psychopy/app/builder/dialogs/__init__.py.orig +1679 -0
- psychopy/app/builder/dialogs/paramCtrls.py +1 -1
- psychopy/app/builder/dialogs/paramCtrls.py.orig +713 -0
- psychopy/app/colorpicker/__init__.py.orig +411 -0
- psychopy/app/cortex.log +0 -0
- psychopy/app/jobs.py +8 -1
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.po +2452 -1731
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN.po +6127 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN_allFlagged.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/zh_CN_allFlagged.po +7366 -0
- psychopy/app/plugin_manager/dialog.py +9 -7
- psychopy/app/ribbon.py +2 -1
- psychopy/app/runner/runner.py +7 -5
- psychopy/clock.py +8 -4
- psychopy/core.py.orig +169 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/index.html +23 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/randomisedBlocks-legacy-browsers.js +423 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/randomisedBlocks.js +427 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/chooseBlock.xlsx +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/facesBlock.xlsx +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/housesBlock.xlsx +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face01.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face02.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/face03.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house01.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house02.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/html/resources/stims/house03.jpg +0 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/randomisedBlocks.py +330 -0
- psychopy/demos/builder/Design Templates/randomisedBlocks/randomisedBlocks_lastrun.py +330 -0
- psychopy/demos/builder/Feature Demos/eyetracking/eyetracking.xml +298 -0
- psychopy/demos/builder/Feature Demos/eyetracking/eyetracking.xsd +120 -0
- psychopy/demos/builder/Tools/.DS_Store +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/.DS_Store +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.csv +38 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.log +3418 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/_gamma_correction_visual_2022-05-18_14h18.29.439.psydat +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.csv +2 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.log +15 -0
- psychopy/demos/builder/Tools/gammaCalibration/data/x1_gamma_correction_visual_2022-05-17_13h59.42.928.psydat +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual.psyexp +323 -0
- psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual.py +562 -0
- psychopy/demos/builder/Tools/gammaCalibration/gamma_correction_visual_lastrun.py +562 -0
- psychopy/demos/builder/Tools/gammaCalibration/questStairs.xlsx +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/readme.md +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/resources/low_contrast.png +0 -0
- psychopy/demos/builder/Tools/gammaCalibration/resources/make_2nd_order_tex.py +59 -0
- psychopy/demos/builder/Tools/gammaCalibration/resources/second_order_tex.png +0 -0
- psychopy/demos/coder/.DS_Store +0 -0
- psychopy/demos/coder/experiment control/info_gamma.pickle +0 -0
- psychopy/demos/coder/iohub/.iohpid +1 -0
- psychopy/demos/coder/iohub/eyetracking/.iohpid +1 -0
- psychopy/demos/coder/iohub/wintab/.DS_Store +0 -0
- psychopy/demos/coder/stimuli/.DS_Store +0 -0
- psychopy/demos/coder/stimuli/radialGratingContracting.py +29 -0
- psychopy/experiment/_experiment.py.orig +1032 -0
- psychopy/experiment/components/.DS_Store +0 -0
- psychopy/experiment/components/_base.py +13 -4
- psychopy/experiment/components/_base.py.orig +823 -0
- psychopy/experiment/components/form/.DS_Store +0 -0
- psychopy/experiment/components/microphone/__init__.py +10 -1
- psychopy/experiment/components/microphone/__init__.py.orig +490 -0
- psychopy/experiment/components/polygon/__init__.py +21 -22
- psychopy/experiment/components/settings/__init__.py +13 -14
- psychopy/experiment/components/settings/__init__.py.orig +1337 -0
- psychopy/experiment/components/textbox/__init__.py.orig +310 -0
- psychopy/experiment/components/webcam/.DS_Store +0 -0
- psychopy/experiment/components/webcam/light/.DS_Store +0 -0
- psychopy/experiment/flow.py +10 -8
- psychopy/experiment/loops.py.orig +829 -0
- psychopy/experiment/params.py +8 -3
- psychopy/experiment/params.py.orig +408 -0
- psychopy/experiment/routine.py.orig +503 -0
- psychopy/experiment/routines/_base.py +15 -6
- psychopy/experiment/routines/counterbalance/__init__.py +1 -0
- psychopy/gui/qtgui.py +14 -7
- psychopy/gui/util.py +10 -14
- psychopy/gui/wxgui.py +10 -4
- psychopy/hardware/.DS_Store +0 -0
- psychopy/hardware/brainproducts.py.orig +680 -0
- psychopy/hardware/iolab.py.orig +238 -0
- psychopy/hardware/manager.py +1 -1
- psychopy/hardware/photodiode.py +59 -27
- psychopy/hardware/serialport.py +51 -0
- psychopy/hardware/speaker.py +4 -4
- psychopy/iohub/datastore/__init__.py.orig +443 -0
- psychopy/iohub/datastore/util.py.orig +692 -0
- psychopy/iohub/devices/mouse/darwin.py.orig +427 -0
- psychopy/iohub/devices/mouse/linux2.py.orig +198 -0
- psychopy/preferences/.DS_Store +0 -0
- psychopy/projects/pavlovia.py +10 -3
- psychopy/projects/pavlovia.py.orig +1295 -0
- psychopy/sound/backend_ptb.py +22 -5
- psychopy/sound/transcribe.py +24 -4
- psychopy/tests/.DS_Store +0 -0
- psychopy/tests/data/.DS_Store +0 -0
- psychopy/tests/data/TestCircle_fill_local.png +0 -0
- psychopy/tests/data/__test.png +0 -0
- psychopy/tests/data/aperture1_normHexbackground_local.png +0 -0
- psychopy/tests/data/aperture1_norm_local.png +0 -0
- psychopy/tests/data/aperture2_normHexbackground_local.png +0 -0
- psychopy/tests/data/beatandrcos_height_local.png +0 -0
- psychopy/tests/data/beatandrcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/beatandrcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/beatandrcos_norm_local.png +0 -0
- psychopy/tests/data/beatandrcos_stencil_local.png +0 -0
- psychopy/tests/data/blend_add_height_local.png +0 -0
- psychopy/tests/data/blend_add_normAddBlend_local.png +0 -0
- psychopy/tests/data/blend_add_normHexbackground_local.png +0 -0
- psychopy/tests/data/blend_add_normNoShade_local.png +0 -0
- psychopy/tests/data/blend_add_norm_local.png +0 -0
- psychopy/tests/data/blend_add_stencil_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_height_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_normAddBlend_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_normHexbackground_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_normNoShade_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_norm_local.png +0 -0
- psychopy/tests/data/bufferimg_gabor_stencil_local.png +0 -0
- psychopy/tests/data/circleHex_height_local.png +0 -0
- psychopy/tests/data/circleHex_normAddBlend_local.png +0 -0
- psychopy/tests/data/circleHex_normHexbackground_local.png +0 -0
- psychopy/tests/data/circleHex_normNoShade_local.png +0 -0
- psychopy/tests/data/circleHex_norm_local.png +0 -0
- psychopy/tests/data/circleHex_stencil_local.png +0 -0
- psychopy/tests/data/color_comparison_local.png +0 -0
- psychopy/tests/data/corrFullRandom_local.csv +16 -0
- psychopy/tests/data/corrFullRandom_local.tsv +6 -0
- psychopy/tests/data/correctScript/.DS_Store +0 -0
- psychopy/tests/data/dots_height_local.png +0 -0
- psychopy/tests/data/dots_normAddBlend_local.png +0 -0
- psychopy/tests/data/dots_normHexbackground_local.png +0 -0
- psychopy/tests/data/dots_normNoShade_local.png +0 -0
- psychopy/tests/data/dots_norm_local.png +0 -0
- psychopy/tests/data/dots_stencil_local.png +0 -0
- psychopy/tests/data/elarray1_height_local.png +0 -0
- psychopy/tests/data/elarray1_normAddBlend_local.png +0 -0
- psychopy/tests/data/elarray1_normHexbackground_local.png +0 -0
- psychopy/tests/data/elarray1_norm_local.png +0 -0
- psychopy/tests/data/elarray1_stencil_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_height_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_norm_local.png +0 -0
- psychopy/tests/data/envelopeandrcos_stencil_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_height_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_norm_local.png +0 -0
- psychopy/tests/data/envelopepowerandrcos_stencil_local.png +0 -0
- psychopy/tests/data/gabor1_height_local.png +0 -0
- psychopy/tests/data/gabor1_normAddBlend_local.png +0 -0
- psychopy/tests/data/gabor1_normHexbackground_local.png +0 -0
- psychopy/tests/data/gabor1_normNoShade_local.png +0 -0
- psychopy/tests/data/gabor1_norm_local.png +0 -0
- psychopy/tests/data/gabor1_stencil_local.png +0 -0
- psychopy/tests/data/greyscale_normHexbackground_local.png +0 -0
- psychopy/tests/data/imageAndGauss_height_local.png +0 -0
- psychopy/tests/data/imageAndGauss_normAddBlend_local.png +0 -0
- psychopy/tests/data/imageAndGauss_normHexbackground_local.png +0 -0
- psychopy/tests/data/imageAndGauss_normNoShade_local.png +0 -0
- psychopy/tests/data/imageAndGauss_norm_local.png +0 -0
- psychopy/tests/data/imageAndGauss_stencil_local.png +0 -0
- psychopy/tests/data/movFrame1_stencil_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_height_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_normNoShade_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_norm_local.png +0 -0
- psychopy/tests/data/noiseAndRcos_stencil_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_height_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_normAddBlend_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_normHexbackground_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_normNoShade_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_norm_local.png +0 -0
- psychopy/tests/data/noiseFiltersAndRcos_stencil_local.png +0 -0
- psychopy/tests/data/numpyImage_height_local.png +0 -0
- psychopy/tests/data/numpyImage_normAddBlend_local.png +0 -0
- psychopy/tests/data/numpyImage_normHexbackground_local.png +0 -0
- psychopy/tests/data/numpyImage_normNoShade_local.png +0 -0
- psychopy/tests/data/numpyImage_norm_local.png +0 -0
- psychopy/tests/data/numpyImage_stencil_local.png +0 -0
- psychopy/tests/data/shape2_1_normAddBlend_local.png +0 -0
- psychopy/tests/data/shape2_1_normHexbackground_local.png +0 -0
- psychopy/tests/data/shape2_1_normNoShade_local.png +0 -0
- psychopy/tests/data/shape2_1_norm_local.png +0 -0
- psychopy/tests/data/shape2_1_stencil_local.png +0 -0
- psychopy/tests/data/testLoopsBlocks.psyexp_local.py +328 -0
- psychopy/tests/data/text1_height_local.png +0 -0
- psychopy/tests/data/text1_normAddBlend_local.png +0 -0
- psychopy/tests/data/text1_normHexbackground_local.png +0 -0
- psychopy/tests/data/text1_norm_local.png +0 -0
- psychopy/tests/data/text1_stencil_local.png +0 -0
- psychopy/tests/data/text2_height.png +0 -0
- psychopy/tests/data/text2_normAddBlend.png +0 -0
- psychopy/tests/data/text2_normHexbackground.png +0 -0
- psychopy/tests/data/text2_stencil.png +0 -0
- psychopy/tests/data/wedge1_height_local.png +0 -0
- psychopy/tests/data/wedge1_normAddBlend_local.png +0 -0
- psychopy/tests/data/wedge1_normHexbackground_local.png +0 -0
- psychopy/tests/data/wedge1_normNoShade_local.png +0 -0
- psychopy/tests/data/wedge1_norm_local.png +0 -0
- psychopy/tests/data/wedge1_stencil_local.png +0 -0
- psychopy/tests/test_app/.DS_Store +0 -0
- psychopy/tests/test_app/test_builder/.DS_Store +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.csv +9 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.log +177 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.psydat +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1206.xlsx +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.csv +9 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.log +168 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.psydat +0 -0
- psychopy/tests/test_app/test_builder/data/_2021_ 5_03_1324.xlsx +0 -0
- psychopy/tests/test_data/.DS_Store +0 -0
- psychopy/tests/test_hardware/test_CRS_BitsSharp.py.orig +68 -0
- psychopy/tests/test_tools/test_arraytools.py +112 -0
- psychopy/tests/test_visual/test_image.py.orig +219 -0
- psychopy/tools/arraytools.py +47 -0
- psychopy/tools/versionchooser.py +1 -1
- psychopy/visual/backends/pygletbackend.py +26 -8
- psychopy/visual/basevisual.py.orig +1723 -0
- psychopy/visual/form.py.orig +1181 -0
- psychopy/visual/text.py.orig +752 -0
- psychopy/visual/textbox2/textbox2.py.orig +1315 -0
- psychopy/visual/window.py +13 -5
- psychopy/visual/windowwarp.py.orig +463 -0
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/METADATA +9 -9
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/RECORD +244 -78
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/WHEEL +1 -1
- {psychopy-2024.2.1.dist-info → psychopy-2024.2.4.dist-info}/entry_points.txt +2 -0
- psychopy/app/locale/ar_001/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/cs_CZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/da_DK/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/de_DE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/el_GR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_NZ/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/en_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_CO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_ES/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/es_US/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/et_EE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fa_IR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fi_FI/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/fr_FR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/he_IL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hi_IN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/hu_HU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/it_IT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ja_JP/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ko_KR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ms_MY/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nl_NL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/nn_NO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pl_PL/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/pt_PT/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ro_RO/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/ru_RU/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/sv_SE/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/tr_TR/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_CN/LC_MESSAGE/messages.mo +0 -0
- psychopy/app/locale/zh_TW/LC_MESSAGE/messages.mo +0 -0
- psychopy-2024.2.1.dist-info/licenses/AUTHORS.md +0 -138
- /psychopy/{app/locale/ar_001/LC_MESSAGE → demos/builder}/.DS_Store +0 -0
- /psychopy/{app/locale/es_ES/LC_MESSAGE → demos/builder/Experiments}/.DS_Store +0 -0
- /psychopy/{visual → demos/builder/Tools/gammaCalibration/data}/.DS_Store +0 -0
- {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
|