#!/usr/bin/env python# -*- coding: utf-8 -*-# Part of the PsychoPy library# Copyright (C) 2002-2018 Jonathan Peirce (C) 2019-2024 Open Science Tools Ltd.# Distributed under the terms of the GNU General Public License (GPL)."""Functions and classes related to file and directory handling"""importosimportshutilimportsubprocessimportsysimportatexitimportcodecsimportnumpyasnpimportjsonimportjson_trickstry:importcPickleaspickleexceptImportError:importpicklefrompsychopyimportloggingfrompsychopy.tools.fileerrortoolsimporthandleFileCollisionfrompathlibimportPathdef_synonymiseExtensions(assets):""" Synonymise filetypes which refer to the same media types. Parameters ========== assets : dict Dict of {name: path} pairs Returns ========== dict Same dict which was passed in, but any names ending in a recognised extension will have variants with the same stem but different (and synonymous) extensions, pointing to the same path. For example: {"default.png": "default.png"} becomes {"default.png": "default.png", "default.jpg": "default.png", "default.jpeg": "default.png"} """# Alias filetypesnewAssets={}forkey,valinassets.items():# Skip if no extif"."notinkey:continue# Get stem and extstem,ext=key.split(".")# Synonymise image typesimgTypes=("png","jpg","jpeg")ifextinimgTypes:forthisExtinimgTypes:newAssets[stem+"."+thisExt]=val# Synonymise movie typesmovTypes=("mp4","mov","mkv","avi","wmv")ifextinmovTypes:forthisExtinmovTypes:newAssets[stem+"."+thisExt]=val# Synonymise audio typessndTypes=("mp3","wav")ifextinsndTypes:forthisExtinsndTypes:newAssets[stem+"."+thisExt]=valreturnnewAssets# Names accepted by stimulus classes & the filename of the default stimulus to usedefaultStimRoot=Path(__file__).parent.parent/"assets"defaultStim={# Image stimuli"default.png":"default.png",# Movie stimuli"default.mp4":"default.mp4",# Sound stimuli"default.mp3":"default.mp3",# Credit card image"creditCard.png":"creditCard.png","CreditCard.png":"creditCard.png","creditcard.png":"creditCard.png",# USB image"USB.png":"USB.png","usb.png":"USB.png",# USB-C image"USB-C.png":"USB-C.png","USB_C.png":"USB-C.png","USBC.png":"USB-C.png","usb-c.png":"USB-C.png","usb_c.png":"USB-C.png","usbc.png":"USB-C.png",}defaultStim=_synonymiseExtensions(defaultStim)
[docs]deftoFile(filename,data):"""Save data (of any sort) as a pickle file. simple wrapper of the cPickle module in core python """f=open(filename,'wb')pickle.dump(data,f)f.close()
[docs]deffromFile(filename,encoding='utf-8-sig'):"""Load data from a psydat, pickle or JSON file. Parameters ---------- encoding : str The encoding to use when reading a JSON file. This parameter will be ignored for any other file type. """filename=pathToString(filename)iffilename.endswith('.psydat'):withopen(filename,'rb')asf:try:contents=pickle.load(f)exceptUnicodeDecodeError:f.seek(0)# reset to start of file to try againcontents=pickle.load(f,encoding='latin1')# python 2 data files# if loading an experiment file make sure we don't save further# copies using __del__ifhasattr(contents,'abort'):contents.abort()returncontentseliffilename.endswith('pickle'):withopen(filename,'rb')asf:contents=pickle.load(f)returncontentseliffilename.endswith('.json'):withcodecs.open(filename,'r',encoding=encoding)asf:contents=json_tricks.load(f)# Restore RNG if we load a TrialHandler2 object.# We also need to remove the 'temporary' ._rng_state attribute that# was saved with it.frompsychopy.dataimportTrialHandler2ifisinstance(contents,TrialHandler2):contents._rng=np.random.default_rng()contents._rng.bit_generator.state=contents._rng_statedelcontents._rng_statereturncontents# QuestPlus.ifsys.version_info.major==3andsys.version_info.minor>=6:frompsychopy.data.staircaseimportQuestPlusHandlerfromquestplusimportQuestPlusifisinstance(contents,QuestPlusHandler):# Restore the questplus.QuestPlus object.contents._qp=QuestPlus.from_json(contents._qp_json)delcontents._qp_jsonreturncontents# If we haven't returned anything by now, the loaded object is neither# a TrialHandler2 nor a QuestPlus instance. Return it unchanged.returncontentselse:msg="Don't know how to handle this file type, aborting."raiseValueError(msg)
[docs]defmergeFolder(src,dst,pattern=None):"""Merge a folder into another. Existing files in `dst` folder with the same name will be overwritten. Non-existent files/folders will be created. """# dstdir must exist firstsrcnames=os.listdir(src)fornameinsrcnames:srcfname=os.path.join(src,name)dstfname=os.path.join(dst,name)ifos.path.isdir(srcfname):ifnotos.path.isdir(dstfname):os.makedirs(dstfname)mergeFolder(srcfname,dstfname)else:try:# copy without metadata:shutil.copyfile(srcfname,dstfname)exceptIOErroraswhy:print(why)
[docs]defopenOutputFile(fileName=None,append=False,fileCollisionMethod='rename',encoding='utf-8-sig'):"""Open an output file (or standard output) for writing. :Parameters: fileName : None, 'stdout', or str The desired output file name. If `None` or `stdout`, return `sys.stdout`. Any other string will be considered a filename. append : bool, optional If ``True``, append data to an existing file; otherwise, overwrite it with new data. Defaults to ``True``, i.e. appending. fileCollisionMethod : string, optional How to handle filename collisions. Valid values are `'rename'`, `'overwrite'`, and `'fail'`. This parameter is ignored if ``append`` is set to ``True``. Defaults to `rename`. encoding : string, optional The encoding to use when writing the file. This parameter will be ignored if `append` is `False` and `fileName` ends with `.psydat` or `.npy` (i.e. if a binary file is to be written). Defaults to ``'utf-8'``. :Returns: f : file A writable file handle. """fileName=pathToString(fileName)if(fileNameisNone)or(fileName=='stdout'):returnsys.stdoutifappend:mode='a'else:iffileName.endswith(('.psydat','.npy')):mode='wb'else:mode='w'# Rename the output file if a file of that name already exists# and it should not be appended.ifos.path.exists(fileName)andnotappend:fileName=handleFileCollision(fileName,fileCollisionMethod=fileCollisionMethod)# Do not use encoding when writing a binary file.if'b'inmode:encoding=Noneifos.path.exists(fileName)andmodein['w','wb']:logging.warning('Data file %s will be overwritten!'%fileName)# The file will always be opened in binary writing mode,# see https://docs.python.org/2/library/codecs.html#codecs.openf=codecs.open(fileName,mode=mode,encoding=encoding)returnf
[docs]defgenDelimiter(fileName):"""Return a delimiter based on a filename. :Parameters: fileName : string The output file name. :Returns: delim : string A delimiter picked based on the supplied filename. This will be ``,`` if the filename extension is ``.csv``, and a tabulator character otherwise. """fileName=pathToString(fileName)iffileName.endswith(('.csv','.CSV')):delim=','else:delim='\t'returndelim
defgenFilenameFromDelimiter(filename,delim):# If no known filename extension was specified, derive a one from the# delimiter.filename=pathToString(filename)ifnotfilename.endswith(('.dlm','.DLM','.tsv','.TSV','.txt','.TXT','.csv','.CSV','.psydat','.npy','.json')):ifdelim==',':filename+='.csv'elifdelim=='\t':filename+='.tsv'else:filename+='.txt'returnfilenamedefconstructLegacyFilename(filename):# make path object from filenamefilename=Path(filename)# construct legacy variant namelegacyName=filename.parent/(filename.stem+"_legacy"+filename.suffix)returnlegacyNameclassDictStorage(dict):"""Helper class based on dictionary with storage to json """def__init__(self,filename,*args,**kwargs):dict.__init__(self,*args,**kwargs)self.filename=filenameself.load()self._deleted=Falseatexit.register(self.__del__)defload(self,filename=None):"""Load all tokens from a given filename (defaults to ~/.PsychoPy3/pavlovia.json) """iffilenameisNone:filename=self.filenameifos.path.isfile(filename):withopen(filename,'r')asf:try:self.update(json.load(f))exceptValueError:logging.error("Tried to load %s but it wasn't valid ""JSON format"%filename)defsave(self,filename=None):"""Save all tokens from a given filename (defaults to the filename given to the class but can be overridden) """iffilenameisNone:filename=self.filename# make sure the folder existsfolder=os.path.split(filename)[0]ifnotos.path.isdir(folder):os.makedirs(folder)# save the file as jsonwithopen(filename,'wb')asf:json_str=json.dumps(self,indent=2,sort_keys=True)f.write(bytes(json_str,'UTF-8'))def__del__(self):ifnotself._deleted:self.save()self._deleted=TrueclassKnownProjects(DictStorage):defsave(self,filename=None):"""Purge unnecessary projects (without a local root) and save"""toPurge=[]forprojnameinself:proj=self[projname]ifnotproj['localRoot']:toPurge.append(projname)forprojnameintoPurge:delself[projname]DictStorage.save(self,filename)defpathToString(filepath):""" Coerces pathlib Path objects to a string (only python version 3.6+) any other objects passed to this function will be returned as is. This WILL NOT work with on Python 3.4, 3.5 since the __fspath__ under method did not exist in those versions, however psychopy does not support these versions of python anyways. :Parameters: filepath : str or pathlib.Path file system path that needs to be coerced into a string to use by Psychopy's internals :Returns: filepath : str or same as input object file system path coerced into a string type """ifhasattr(filepath,"__fspath__"):returnfilepath.__fspath__()returnfilepathdefopenInExplorer(path):""" Open a given director path in current operating system's file explorer. """# Choose a command according to OSifsys.platformin['win32']:comm="explorer"elifsys.platformin['darwin']:comm="open"elifsys.platformin['linux','linux2']:comm="dolphin"# Use command to open folderret=subprocess.call(" ".join([comm,path]),shell=True)returnret