Team Development with Zope (7) can be problematic (1). The ZODB is a large binary file (var/Data.fs) that stores all information about a Zope instance, the ZODB is opaque to all but python. The ZODB is typically accessed Through The Web (TTW) using the Zope Management Interface (ZMI) typically http://localhost:8080/manage. All data controlled and published by Zope are held in this ZODB, while almost all available Source Control Management (SCM) and replication tools needed for version management and staging require a traditional file system representation of data. Though there is a simple version control mechanism built-in to the ZODB (one development line for single objects), this soon proves insufficient even for small applications (4). This post shows how to set up a zeo server with 2 clients, one acting as a web server, the other as an ipython interpreter (this gives us access to the ZODB object for debugging purposes) The following are typical requirements for Team Development in Plone:
- Use Source Control (CVS, Subversion, etc.).
- Setup environment so debugging is possible (11)
- File system based.
- Don't modify Zope or Plone through the ZMI so that different versions of Zope/Plone can be setup and tested without requiring TTW intervention.
- Allow off line development (Don't use a central development server)
- Objects (Python Scripts, Images ZSQL Methods, etc.) are stored as binary objects in the Zope Object Database (ZODB).
- Can't use file system utilities such as grep, find for development.
- Editing documents is painful if your "favorite editor" doesn't work with the external editor tool (9).
- Can't use source control management, e.g. CVS, Subversion, on objects in the ZODB (exception: (CVSFile) and (ExternalFile) provide CVS through the ZMI).
- Can't write test suites for binary objects in database.
- Do not use ZMI at all ever!, use Filesystem Products (2) (6) (16).
- Build products for each major feature (19) (20)
- Build a site product for site customization (16)
- Avoid disconnected External methods, scripts etc.
- Write scripts for creating your Plone instance and configuration (involves learning Zope/Plone api), commit these scripts to SCM.
mkdir ~/plonedev cd ~/plonedev wget -c http://homepage.eircom.net/~rachra/setupplonezeo.sh wget -c http://homepage.eircom.net/~rachra/plonehelper.py chmod +x setupplonezeo.sh ./setupplonezeo.sh 1 Acme
~/plonedev/ startPlone2.1-rc3.sh # this script starts the zeo server on port 8100 and plone listening on port 8080 startIPythonPlone2.1-rc3.sh # this script starts an IPython interpreter on your zeo_client1 (port 9080) debugPlone2.1-rc3.py # a python script to start zeo_client0 (port 8080) plonehelper.py # python helper script for creating a plone instance # installing products and selecting a skin using the plone api myproducts/ # this is where you develop your Zope/Plone products build/ # the directory where you built Zope and Python src/ # the directory where all downloads get stored zeo_py2.3.5_zo2.8.1-final_pl2.1-rc3/ Python-2.3.5/ # a full python installation including ipython Zope-2.8.1-final/ # a full zope installation thirdpartyproducts/ # all 3rd party products for both zeo clients are stored (symlinked) here, # you shouldn't edit these when developing as you may break your upgrade path zeo_server/ # the zeo server that contains the shared ZODB zeo_client0/ # the zeo client that will act as our webserver on port 8080 zeo_client1/ # the zeo client that will act as our interactive debugger (port 9080)
./startPlone2.1-rc3.sh
./startIPythonPlone2.1-rc3.sh
115:# colors - Coloring option for prompts and traceback printouts. 116: 117:# Currently available schemes: NoColor, Linux, LightBG. 118: 119:# This option allows coloring the prompts and traceback printouts. This 120:# requires a terminal which can properly handle color escape sequences. If you 121:# are having problems with this, use the NoColor scheme (uses no color escapes 122:# at all). 123: 124:# The Linux option works well in linux console type environments: dark 125:# background with light fonts. 126: 127:# LightBG is similar to Linux but swaps dark/light colors to be more readable 128:# in light background terminals. 129: 130:# keep uncommented only the one you want: 131:#colors Linux 132:colors LightBG 133:#colors NoColor 134:
from Products.CMFPlone import transaction for id, ref in app.objectItems(): print "%-25s: %s"% (id, repr(ref)) app.Acme.objectIds?? app.Acme.objectIds() for k,v in app.Acme.contentItems(): print "%-11s: %s"%(k,repr(v)) # try out any of your own commands here, # DocFinderTab is installed and viewable through the ZMI for help with the Zope api # to synchronize with the ZODB app._p_jar.sync() # to commit your changes to the database transaction.commit()
./setupplonezeo.sh 0 TestSite 8888
~/plonedev/ startPlone2.1-rc3.sh # this script starts the zeo server on port 8100 and plone listening on port 8080 startPlone2.0.5.sh # this script starts the zeo server on port 9999 abd plone listening on port 8888 startIPythonPlone2.1-rc3.sh # this script starts an IPython interpreter on your zeo_client1 (port 9080) startIPythonPlone2.0.5.sh # this script starts an IPython interpreter on your zeo_client1 (port 9888) debugPlone2.1-rc3.py # a python script to start zeo_client0 (port 8080) debugPlone2.0.5.py # a python script to start zeo_client0 (port 9080) plonehelper.py # python helper script for creating a plone instance # installing products and selecting a skin using the plone api myproducts/ # this is where you develop your Zope/Plone products build/ # the directory where you built Zope and Python src/ # the directory where all downloads get stored zeo_py2.3.5_zo2.8.1-final_pl2.1-rc3/ Python-2.3.5/ # a full python installation including ipython Zope-2.8.1-final/ # a full zope installation thirdpartyproducts/ # all 3rd party products for both zeo clients are stored (symlinked) here, # you shouldn't edit these when developing as you may break your upgrade path zeo_server/ # the zeo server that contains the shared ZODB zeo_client0/ # the zeo client that will act as our webserver on port 8080 zeo_client1/ # the zeo client that will act as our interactive debugger (port 9080) zeo_py2.3.4_zo2.7.4_pl2.0.5/ Python2.3.4/ # a full python installation including ipython Zope-2.7.4-0/ # a full zope installation thirdpartyproducts/ # all 3rd party products for both zeo clients are stored (symlinked) here, # you shouldn't edit these when developing as you may break your upgrade path zeo_server/ # the zeo server that contains the shared ZODB zeo_client0/ # the zeo client that will act as our webserver on port 8888 zeo_client1/ # the zeo client that will act as our interactive debugger (port 9888)
./setupplonezeo.sh 0 TestSite 8888 63
- Why not to use Zope: http://www.amk.ca/python/writing/why-not-zope.html
- Plone best practices: https://plone.org/documentation/tutorial/best-practices/
- Team development with Plone/Zope: http://www.zope.org/Members/k_vertigo/Stories/TeamZope
- zsync: http://www.elegosoft.com/index_zync.html
- Unit testing plone: http://www.zope.org/Members/shh/Tutorial/PloneTestCase.pdf
- Scripting plone: http://docs.neuroinf.de/programming-plone
- Beginners guide to Zope/Plone: http://www.neuroinf.de/Miscellaneous/BeginnersGuide
- Introduction to Plone: http://docs.neuroinf.de/PloneBook
- Zope External Editor: http://www.zope.org/Members/Caseman/ExternalEditor
- Zope Test Case: http://www.zope.org/Members/shh/ZopeTestCase
- Debugging Zope: http://plone.org/Members/pupq/debug
- Debugging Zope: http://www.zope.org/Members/klm/ZopeDebugging/ConversingWithZope
- ZEO and debugging: http://www.zope.org/Members/dshaw/AdvancedSiteSetup
- Installing ZEO: http://www.plope.com/Books/2_7Edition/ZEO.stx
- Debugging Zope: http://zopewiki.org/DebuggingZopeWithPythonDebugger2
- MySkin Plone customization: http://www.ucl.ac.uk/is/fiso/engsciences/tutorials/plone/plone_pages/customise_plone2.htm
- Archetypes Development environment setup: http://plone.org/documentation/archetypes/wiki/SettingUpArchetypesProductDevelopmentEnvironment
- Archetypes home page: http://plone.org/documentation/archetypes
- Archetypes developers guide: http://plone.org/documentation/archetypes/ArchetypesDeveloperGuide
- ArchGenXML: http://plone.org/documentation/tutorial/archgenxml-getting-started
- Robust Plone installation with ZEO: http://plone.org/documentation/tutorial/robust-installation
- ipython: http://ipython.scipy.org/
- ipython tutorial: http://www.onlamp.com/pub/a/python/2005/01/27/ipython.html
- BoaDebugger: http://plone.org/documentation/how-to/how-to-debug-products-with-boa-constructor
- Installing Zope and Pone http://docs.neuroinf.de/programming-plone/appendixC#1-5
setupplonezeo.sh
1:#!/bin/bash 2:E_NOARGS=65 3:PloneId="Acme" 4:ZOPE_PORT=8080 5:SLEEPTIME=20 6:if [ -z "$1" ] 7:then 8: echo "Usage: `basename $0` [plone-version, 0 or 1] [ploneid, default $PloneId] [zope-port, default $ZOPE_PORT] [sleep-time, default $SLEEPTIME]" 9: echo "e.g. to build Plone 2.1 with a PloneId of TestSite allowing 15 seconds for server to start on port 8888" 10: echo "`basename $0` 1 TestSite 8888 15" 11: exit $E_NOARGS 12:fi 13:#set -x 14: 15:base=`pwd` 16:mkdir $base/{src,build} 17:cd $base/src 18:if [ "$1" -ne "0" ] 19:then 20: PythonVersion="2.3.5" 21: ZopeVersion="2.8.1-final" 22: PloneVersion="2.1-rc3" 23: ZEO_PORT=8100 24: wget -c http://www.zope.org/Products/Zope/2.8.1/Zope-$ZopeVersion.tgz 25:else 26: PythonVersion="2.3.4" 27: ZopeVersion="2.7.4-0" 28: PloneVersion="2.0.5" 29: ZEO_PORT=9999 30: wget -c http://www.zope.org/Products/Zope/2.7.4/Zope-$ZopeVersion.tgz 31:fi 32:StartPloneScript=startPlone$PloneVersion.sh 33:StopPloneScript=stopPlone$PloneVersion.sh 34:StartIPythonScript=startIPython$PloneVersion.sh 35:PythonDebugScript=debugPlone$PloneVersion.py 36:if [ -n "$2" ] 37:then 38: PloneId=$2 39:fi 40:if [ -n "$3" ] 41:then 42: ZOPE_PORT=$3 43:fi 44:if [ -n "$4" ] 45:then 46: SLEEPTIME=$4 47:fi 48:PloneTitle=$PloneId 49:PloneDescription="CMS Prototype" 50:SkinName="${PloneId}Skin" 51:INSTALL_HOME=$base/zeo_py${PythonVersion}_zo${ZopeVersion}_pl$PloneVersion 52:# remove existing install 53:rm -rf $INSTALL_HOME 54:PYTHON_HOME=$INSTALL_HOME/Python-$PythonVersion 55:ZOPE_HOME=$INSTALL_HOME/Zope-$ZopeVersion 56:PYTHON=$PYTHON_HOME/bin/python 57:PRODUCTS_DIR=$INSTALL_HOME/thirdpartyproducts 58:MYPRODUCTS_DIR=$base/myproducts 59:ZEOSERVER_DIR=$INSTALL_HOME/zeo_server 60:ZEOCLIENT0_DIR=$INSTALL_HOME/zeo_client0 61:ZEOCLIENT1_DIR=$INSTALL_HOME/zeo_client1 62:INSTANCE_USER=manager 63:INSTANCE_PASSWORD=manager 64: 65:# Get Python, Zope, Plone, MySkin, ipython and a sample logo 66:wget -c http://www.python.org/ftp/python/$PythonVersion/Python-$PythonVersion.tgz 67:wget -c http://heanet.dl.sourceforge.net/sourceforge/plone/Plone-$PloneVersion.tar.gz 68:wget -c http://plone.org/products/myskin/releases/0.1/MySkin-0.1.tar.gz 69:wget -c http://www.zope.org/Members/shh/DocFinderTab/0.5.2/DocFinderTab-0.5.2.tar.gz 70:wget -c http://homepage.eircom.net/~rachra/acme.jpg 71:wget -c http://ipython.scipy.org/dist/ipython-0.6.15.tar.gz 72:wget -c http://wingware.com/pub/wingide/2.0.3/WingDBG-2.0.3-2.tar 73: 74:# build Python 75:cd $base/build 76:tar -zxf $base/src/Python-$PythonVersion.tgz 77:cd Python-$PythonVersion 78:./configure --prefix=$PYTHON_HOME --enable-unicode=ucs4 79:make 80:make install 81: 82:# install ipython 83:cd $base/build 84:tar -zxvf $base/src/ipython-0.6.15.tar.gz 85:cd ipython-0.6.15 86:$PYTHON setup.py install 87: 88:# build Zope 89:cd $base/build 90:tar -zxf $base/src/Zope-$ZopeVersion.tgz 91:cd Zope-$ZopeVersion 92:./configure --prefix=$ZOPE_HOME --with-python=$PYTHON 93:make 94:make install 95: 96:# make a zeo server instance 97:$PYTHON $ZOPE_HOME/bin/mkzeoinstance.py $ZEOSERVER_DIR 98: 99:# make 2 zeo client instances 100:$PYTHON $ZOPE_HOME/bin/mkzopeinstance.py -d $ZEOCLIENT0_DIR -u $INSTANCE_USER:$INSTANCE_PASSWORD 101:$PYTHON $ZOPE_HOME/bin/mkzopeinstance.py -d $ZEOCLIENT1_DIR -u $INSTANCE_USER:$INSTANCE_PASSWORD 102: 103:# unzip Plone and copy into Products directory 104:mkdir $PRODUCTS_DIR 105:cd $base/build 106:tar -zxf $base/src/Plone-$PloneVersion.tar.gz 107:cd Plone-$PloneVersion 108:cp -R * $PRODUCTS_DIR 109: 110:# install docfinder 111:cd $PRODUCTS_DIR 112:tar -zxf $base/src/DocFinderTab-0.5.2.tar.gz 113: 114:# install WingDebugger 115:tar -xf $base/src/WingDBG-2.0.3-2.tar 116: 117:# install our own filesystem skin, this would come from source control normally 118:# install MySkin Product 119:mkdir $MYPRODUCTS_DIR 120:cd $MYPRODUCTS_DIR 121:tar -zxf $base/src/MySkin-0.1.tar.gz 122: 123:# move MySkin directories to $SkinName 124:mv MySkin $SkinName 125:mv $SkinName/skins/MySkin $SkinName/skins/$SkinName 126: 127:# Convert all instances of MySkin to $SkinName in all files 128:cd $SkinName 129:find . -exec sed -i -e "s/MySkin/$SkinName/g" {} \; 130: 131:# copy a logo in here to test if filesystem skin is working 132:cp $base/src/acme.jpg $MYPRODUCTS_DIR/$SkinName/skins/$SkinName/logo.jpg 133: 134:# symlink Product directories between clients 135:rm -rf $ZEOCLIENT0_DIR/Products 136:ln -s $PRODUCTS_DIR $ZEOCLIENT0_DIR/Products 137:rm -rf $ZEOCLIENT1_DIR/Products 138:ln -s $PRODUCTS_DIR $ZEOCLIENT1_DIR/Products 139: 140:# write out the zope.conf files for each client 141:# client0 listens on port $ZOPE_PORT 142:cat << EOF > $ZEOCLIENT0_DIR/etc/zope.conf 143:%define INSTANCE $ZEOCLIENT0_DIR 144:%define ZOPE $ZOPE_HOME 145:instancehome \$INSTANCE 146:debug-mode on 147:products $MYPRODUCTS_DIR 148:<eventlog> 149: level info 150: <logfile> 151: path \$INSTANCE/log/event.log 152: level info 153: </logfile> 154:</eventlog> 155:<logger access> 156: level WARN 157: <logfile> 158: path \$INSTANCE/log/Z2.log 159: format %(message)s 160: </logfile> 161:</logger> 162:<http-server> 163: # valid keys are "address" and "force-connection-close" 164: address $ZOPE_PORT 165: # force-connection-close on 166:</http-server> 167:<zodb_db temporary> 168: # Temporary storage database (for sessions) 169: <temporarystorage> 170: name temporary storage for sessioning 171: </temporarystorage> 172: mount-point /temp_folder 173: container-class Products.TemporaryFolder.TemporaryContainer 174:</zodb_db> 175:<zodb_db main> 176: mount-point / 177: # ZODB cache, in number of objects 178: cache-size 5000 179: <zeoclient> 180: server localhost:$ZEO_PORT 181: storage 1 182: name zeostorage 183: var \$INSTANCE/var 184: # ZEO client cache, in bytes 185: cache-size 20MB 186: # Uncomment to have a persistent disk cache 187: #client zeo1 188: </zeoclient> 189:</zodb_db> 190:EOF 191: 192:# client1 listens on port $ZOPE_PORT+1000 193:cat << EOF > $ZEOCLIENT1_DIR/etc/zope.conf 194:%define INSTANCE $ZEOCLIENT1_DIR 195:%define ZOPE $ZOPE_HOME 196:instancehome \$INSTANCE 197:debug-mode on 198:products $MYPRODUCTS_DIR 199:port-base 1000 200:<eventlog> 201: level info 202: <logfile> 203: path \$INSTANCE/log/event.log 204: level info 205: </logfile> 206:</eventlog> 207:<logger access> 208: level WARN 209: <logfile> 210: path \$INSTANCE/log/Z2.log 211: format %(message)s 212: </logfile> 213:</logger> 214:<http-server> 215: # valid keys are "address" and "force-connection-close" 216: address $ZOPE_PORT 217: # force-connection-close on 218:</http-server> 219:<zodb_db temporary> 220: # Temporary storage database (for sessions) 221: <temporarystorage> 222: name temporary storage for sessioning 223: </temporarystorage> 224: mount-point /temp_folder 225: container-class Products.TemporaryFolder.TemporaryContainer 226:</zodb_db> 227:<zodb_db main> 228: mount-point / 229: # ZODB cache, in number of objects 230: cache-size 5000 231: <zeoclient> 232: server localhost:$ZEO_PORT 233: storage 1 234: name zeostorage 235: var \$INSTANCE/var 236: # ZEO client cache, in bytes 237: cache-size 20MB 238: # Uncomment to have a persistent disk cache 239: #client zeo1 240: </zeoclient> 241:</zodb_db> 242:EOF 243: 244:# start the zeo server 245:$ZEOSERVER_DIR/bin/zeoctl start 246:sleep $SLEEPTIME # give the zeo server a chance to start 247: 248:# add a Plone Site Instance 249:cd $base 250:export PYTHONPATH=$ZOPE_HOME/lib/python:$PYTHONPATH 251:$PYTHON plonehelper.py -c $ZEOCLIENT0_DIR/etc/zope.conf -i $PloneId -t $PloneTitle -d "$PloneDescription" -u $INSTANCE_USER -p $INSTANCE_PASSWORD 252: 253:# Install our new skin product 254:$PYTHON plonehelper.py -c $ZEOCLIENT0_DIR/etc/zope.conf -i $PloneId -n $SkinName 255: 256:# Set the default skin to our skin 257:$PYTHON plonehelper.py -c $ZEOCLIENT0_DIR/etc/zope.conf -i $PloneId -s $SkinName 258: 259:$ZEOSERVER_DIR/bin/zeoctl stop 260: 261:cat << EOF > $base/$StartPloneScript 262:#!/bin/bash 263:set -x 264:$ZEOSERVER_DIR/bin/zeoctl start 265:sleep $SLEEPTIME # to allow zeo to start 266:$ZEOCLIENT0_DIR/bin/zopectl start 267:sleep $SLEEPTIME # to allow client 0 to start 268:echo "Plone site is running on http://localhost:$ZOPE_PORT/$PloneId" 269:EOF 270: 271:cat << EOF > $base/$StopPloneScript 272:#!/bin/bash 273:set -x 274:$ZEOCLIENT0_DIR/bin/zopectl stop 275:$ZEOSERVER_DIR/bin/zeoctl stop 276:EOF 277: 278:chmod +x $base/$StartPloneScript 279:chmod +x $base/$StopPloneScript 280: 281:# set up the ipython zope script 282:cat << EOF > $base/$StartIPythonScript 283:#!/bin/bash 284:set -x 285:export PYTHONPATH=$ZOPE_HOME/lib/python:$PYTHONPATH 286:$PYTHON -i -c "from Zope import configure;configure('$ZEOCLIENT1_DIR/etc/zope.conf');import Zope; app=Zope.app();ns={'__name__':'blah','app':app};import IPython;IPython.Shell.IPShell(user_ns=ns).mainloop(sys_exit=1);" 287:EOF 288: 289:chmod +x $base/$StartIPythonScript 290: 291:# set up a python script to allow debugging from various python debuggers 292:cat << EOF > $base/$PythonDebugScript 293:import sys, os, time 294:print 'starting zeo server' 295:os.system("$ZEOSERVER_DIR/bin/zeoctl start") 296:print 'waiting for $SLEEPTIME seconds to allow zeo server to complete startup' 297:time.sleep($SLEEPTIME) 298:ZOPE_HOME="$ZOPE_HOME" 299:SOFTWARE_HOME= os.path.join(ZOPE_HOME,"lib","python") 300:sys.path.insert(0, SOFTWARE_HOME ) 301:import Zope , ZPublisher 302:sys.argv = ['run.py', '-C', '$ZEOCLIENT0_DIR/etc/zope.conf'] 303:Zope.Startup.run.run() 304:EOF 305: 306:echo "Finished, you can either:" 307:echo "run ./$StartPloneScript to start plone on http://localhost:$ZOPE_PORT/$PloneId" 308:echo "the run ./$StartIPythonScript to inspect zope/plone api and ZODB" 309:echo "run ./$StopPloneScript to stop plone" 310:echo "or run (from your development environment)" 311:echo "python $PythonDebugScript" 312:echo "run ./$StopPloneScript to stop the zeo server when you stop the python script" 313:
1:""" 2:plonehelper.py -c configFile -i plone_id -t plone_title -d plone_description -u username -p password 3: 4:functions to add and adminsiter a Plone Site from script 5:""" 6:import sys 7:import getopt 8:from Zope.Startup.run import configure 9:import Zope 10:import urllib 11:#from Products.CMFPlone import transaction 12:import time 13: 14:version=0.1 15: 16:def add_plone_site(app, plone_id, plone_title, plone_description, username, password): 17: req ='/manage_addProduct/CMFPlone/manage_addSite?id=%s&title=%s&create_userfolder=1&description=%s&custom_policy=Default+Plone&submit=+Add+Plone+Site+'%(urllib.quote(plone_id), urllib.quote(plone_title), urllib.quote(plone_description)) 18: Zope.debug(req,u='%s:%s'%(username, password)) 19: 20:def set_default_skin(ploneSite, skin_name): 21: # set our new skin as the default skin 22: ploneSite.portal_skins.default_skin = skin_name 23: 24:def install_product(ploneSite, product_name): 25: # install the product 26: quick_installer = ploneSite.portal_quickinstaller 27: quick_installer.installProduct(product_name) 28: #print quick_installer.listInstalledProducts() 29: 30:def main(argv = None): 31: if argv is None: 32: argv = sys.argv 33: config_file = None 34: username = '' 35: password = '' 36: plone_id = '' 37: plone_title = None 38: plone_description = '' 39: skin_name = None 40: product_name = None 41: try: 42: opts, args = getopt.getopt(argv[1:], "hvc:i:t:d:u:p:s:n:", ["help", "version"]) 43: except getopt.error, msg: 44: print msg 45: print(__doc__) 46: sys.exit(0) 47: for o, a in opts: 48: if o in ("-h", "--help"): 49: print(__doc__) 50: sys.exit(0) 51: elif o in ("-v", "--version"): 52: log("version: %s"%version) 53: sys.exit(0) 54: elif o in ("-c"): 55: config_file = a 56: elif o in ("-u"): 57: username = a 58: elif o in ("-p"): 59: password = a 60: elif o in ("-i"): 61: plone_id = a 62: elif o in ("-t"): 63: plone_title = a 64: elif o in ("-d"): 65: plone_description = a 66: elif o in ("-s"): 67: skin_name = a 68: elif o in ("-n"): 69: product_name = a 70: 71: if config_file is not None: 72: configure(config_file) 73: app = Zope.app() 74: if plone_id is not None: 75: if plone_title is not None: 76: add_plone_site(app, plone_id, plone_title, plone_description, username, password) 77: else: 78: ploneSite = eval('app.'+plone_id) 79: if ploneSite != None: 80: if product_name is not None: 81: install_product(ploneSite, product_name) 82: if skin_name is not None: 83: set_default_skin(ploneSite, skin_name) 84: else: 85: print 'Could not find plone site with id ', plone_id 86: # commit these changes 87: get_transaction().commit() 88: #transaction.commit() 89: else: 90: print 'you must specify a unique plone id to create a plone site' 91: else: 92: print 'you must supply a configuration file: /path/to/zope.conf' 93: 94:if __name__=='__main__': 95: main()
No comments:
Post a Comment