2020-09-06

49: Create In-Document Python Macro for LibreOffice/OpenOffice

<The previous article in this series | The table of contents of this series | The next article in this series>

The GUI looks as though you cannot, but actually, you can. So, you do not need to use Basic just in order to locate your macro in-document.

Topics


About: UNO (Universal Network Objects)
About: LibreOffice
About: Apache OpenOffice
About: The Python programming language

The table of contents of this article


Starting Context


  • The reader has a basic knowledge on LibreOffice or Apache OpenOffice.
  • The reader has a basic knowledge on the Python programming language.

Target Context


  • The reader will know how to create his or her in-document Python macro for LibreOffice or Apache OpenOffice.

Orientation


Creating your user-owned or application-owned Python macro has been addressed in the previous article.

Executing any macro (user-owned, application-owned, or in-document) function from your UNO program will be addressed in a future article.

The details of Python macro programming will be dug into in some future articles.

Using an external full Python has been addressed in a previous article.

Stage Direction
Here are Hypothesizer 7, Objector 49A, and Objector 49B in front of a computer.


Main Body


1: You Can Locate Your Python Macro in Any 'odt', 'ods', 'odp', 'odb', 'odf', or 'odg' File


Hypothesizer 7
You can locate your Python macro in any 'odt', 'ods', 'odp', 'odb', 'odf', or 'odg' file.

Objector 49A
You aren't serious, right?

Hypothesizer 7
I am very serious, sir.

Objector 49A
But I've heard I can't.

Hypothesizer 7
Then, the one who said so did not know how.

Objector 49A
Everyone said so!

Stage Direction
Hypothesizer 7 shakes his head plaintively.

Hypothesizer 7
I did not, sir . . .

Objector 49A
But the GUI doesn't let me.

Hypothesizer 7
Well, there seems to be a prevalent misconception that the GUI shows what can be done; actually, the GUI shows only what can be done via the GUI.

Objector 49A
. . . Seeing the GUI, everybody will think that any in-document Python macro is not allowed!

Stage Direction
Hypothesizer 7 shakes his head plaintively.

Hypothesizer 7
I will not, sir. You know, the word, "everybody", should not be used carelessly.

Objector 49B
So, you tell me to execute a command to put my macro in-document?

Hypothesizer 7
Madam, in fact, it is more than a single command.

Objector 49B
That sounds difficult!

Hypothesizer 7
Any one who attempts to create a macro should be able to do it all right, in my opinion.

Objector 49A
You shouldn't use "Any one" carelessly!

Hypothesizer 7
I have used it carefully, sir. I literally mean it: you should learn, if you really cannot now.


2: How to Locate Your Python Macro in Any 'odt', 'ods', 'odp', 'odb', 'odf', or 'odg' File


Hypothesizer 7
Any 'odt', 'ods', 'odp', 'odb', 'odf', or 'odg' file is really a ZIP file.

Objector 49B
Is that so? Can I just expand the file?

Hypothesizer 7
Yes, you can.

For example, let us expand this 'odt' file, 'MacroTests.odt', into a directory, 'fileIngredients', like this, in this Linux machine.

@bash Source Code
l_targetFileName="MacroTests.odt"; l_expansionDirectoryName="fileIngredients"; mkdir "./${l_expansionDirectoryName}"; cp "./${l_targetFileName}" "./${l_expansionDirectoryName}/."; cd "./${l_expansionDirectoryName}"; unzip "./${l_targetFileName}"; rm "./${l_targetFileName}"

Objector 49B
. . . Well, I don't use Linux, but I can just expand the file in whatever way, right?

Hypothesizer 7
Right, madam.

Then, we copy the Python macro file under the 'Scripts/python' directory (which you have to create) in the expansion directory, like this, where 'PythonEnvironmentChecker.py' is the Python macro file.

@bash Source Code
l_macroBasePath=~/"myData/development/pythonEnvironmentCheckerUnoExtension/source/python"; l_macroPackagePath="theBiasPlanet/pythonEnvironmentChecker/macros"; l_macroFileName="PythonEnvironmentChecker.py"; mkdir -p "./Scripts/python/${l_macroPackagePath}"; cp "${l_macroBasePath}/${l_macroPackagePath}/${l_macroFileName}" "./Scripts/python/${l_macroPackagePath}/."

Objector 49B
"theBiasPlanet/pythonEnvironmentChecker/macros"?

Hypothesizer 7
That is just in order to demonstrate that you can put the Python module in any package; of course, you can put the Python module file directly in the 'Scripts/python' directory, if you will.

Objector 49B
Hmm.

Hypothesizer 7
Then, we edit the 'META-INF/manifest.xml' file to add this in the 'manifest:manifest' node, after the existing child nodes.

@xml Source Code
 <manifest:file-entry manifest:full-path="Scripts" manifest:media-type="application/binary">
 </manifest:file-entry>
 <manifest:file-entry manifest:full-path="Scripts/python" manifest:media-type="application/binary">
 </manifest:file-entry>
 <manifest:file-entry manifest:full-path="Scripts/python/theBiasPlanet" manifest:media-type="application/binary">
 </manifest:file-entry>
 <manifest:file-entry manifest:full-path="Scripts/python/theBiasPlanet/pythonEnvironmentChecker" manifest:media-type="application/binary">
 </manifest:file-entry>
 <manifest:file-entry manifest:full-path="Scripts/python/theBiasPlanet/pythonEnvironmentChecker/macros" manifest:media-type="application/binary">
 </manifest:file-entry>
 <manifest:file-entry manifest:full-path="Scripts/python/theBiasPlanet/pythonEnvironmentChecker/macros/PythonEnvironmentChecker.py" manifest:media-type="">
 </manifest:file-entry>

Objector 49B
It's bothersome . . .

Hypothesizer 7
But you can do it, right?

Objector 49B
I don't say I can't, but it's bothersome.

Hypothesizer 7
The last step is to re-archive the expansion directory, like this.

@bash Source Code
zip -r "../${l_targetFileName}" *

Objector 49B
They are bothersome steps.

Hypothesizer 7
On the contrary, you can just execute a shell script or batch file like this, although you have to edit the manifest file still.

@bash Source Code
#!/bin/bash
# supposed to be executed at the target file directory

l_targetFileName="$1"
l_modeName="$2" # 'add' or 'remove'
l_macroBasePath="$3" # empty if the mode name is 'remove'
l_macroPackagePath="$4" # can be empty if the mode name is 'remove' (the 'Scripts' path will be removed)
l_macroFileName="$5" # can be empty if the mode name is 'remove'
l_expansionDirectoryName="fileIngredients"

set -x

cp "./${l_targetFileName}" "./${l_targetFileName}.save"
mkdir "./${l_expansionDirectoryName}"
mv "./${l_targetFileName}" "./${l_expansionDirectoryName}/."
cd "./${l_expansionDirectoryName}"
unzip "./${l_targetFileName}"
rm "./${l_targetFileName}"
if [ "${l_modeName}" == "add" ]; then
        if [ ! -d "./Scripts/python/${l_macroPackagePath}" ]; then
                mkdir -p "./Scripts/python/${l_macroPackagePath}"
        fi
        cp "${l_macroBasePath}/${l_macroPackagePath}/${l_macroFileName}" "./Scripts/python/${l_macroPackagePath}/."
else
        if [ "${l_macroPackagePath}" != "" ]; then
                rm -r "./Scripts/python/${l_macroPackagePath}/${l_macroFileName}"
        else
                rm -r "./Scripts"
        fi
fi
vim "META-INF/manifest.xml"
zip -r "../${l_targetFileName}" *
cd ..
rm -r "./${l_expansionDirectoryName}"

Objector 49B
The editing part is bothersome.

Hypothesizer 7
You can create a Python program that do the editing, like this.

theBiasPlanet/inDocumentPythonMacrosRegistrar/programs/InDocumentPythonMacrosRegistrarConsoleProgram.py

@Python Source Code
from typing import List
from typing import Optional
from typing import TextIO
from typing import cast
from collections import OrderedDict
import os
from pathlib import Path
import shutil
import sys
import xml.sax
import xml.sax.handler
from xml.sax.handler import ContentHandler
from xml.sax.xmlreader import AttributesImpl
from xml.sax.xmlreader import XMLReader
from theBiasPlanet.coreUtilities.constantsGroups.DefaultValuesConstantsGroup import DefaultValuesConstantsGroup
from theBiasPlanet.coreUtilities.constantsGroups.FileNameSuffixesConstantsGroup import FileNameSuffixesConstantsGroup
from theBiasPlanet.coreUtilities.constantsGroups.FileOpenModeNamesConstantsGroup import FileOpenModeNamesConstantsGroup
from theBiasPlanet.coreUtilities.constantsGroups.GeneralConstantsConstantsGroup import GeneralConstantsConstantsGroup
from theBiasPlanet.coreUtilities.constantsGroups.XmlExpressionsConstantsGroup import XmlExpressionsConstantsGroup
from theBiasPlanet.coreUtilities.messagingHandling.Publisher import Publisher
from theBiasPlanet.coreUtilities.xmlDataHandling.XmlDatumHandler import XmlDatumHandler

"""
mode name: "add" or "remove"
"""
class InDocumentPythonMacrosRegistrarConsoleProgram:
	c_manifestFileRelativePath: Path = Path ("META-INF/manifest.xml")
	c_scriptsBaseDirectoryRelativePath: Path = Path ("Scripts")
	c_pythonBaseDirectoryRelativePath: Path = c_scriptsBaseDirectoryRelativePath.joinpath ("python")
	c_addModeName = "add"
	c_removeModeName = "remove"
	class ManifestDatumParseEventsHandler (ContentHandler):
		c_rootElementName: str = "manifest:manifest"
		c_filePathElementName: str = "manifest:file-entry"
		c_pathAttributeName: str = "manifest:full-path"
		c_mediaTypeAttributeName: str = "manifest:media-type"
		c_binaryMediaTypeName = "application/binary"
		c_emptyMediaTypeName = GeneralConstantsConstantsGroup.c_emptyString
		c_pythonModuleFileGlobExpression = GeneralConstantsConstantsGroup.c_fileNameFormat.format (GeneralConstantsConstantsGroup.c_doubleAsterisks, FileNameSuffixesConstantsGroup.c_pythonModuleFileNameSuffix)
		
		def __init__ (a_this: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler", a_modeName: str, a_concernedPathToIsFoundMap: "OrderedDict [Path, bool]", a_writer: TextIO) -> None:
			a_this.i_modeName: str = a_modeName
			a_this.i_concernedPathToIsFoundMap: "OrderedDict [Path, bool]" = a_concernedPathToIsFoundMap
			a_this.i_writer: TextIO = a_writer
			a_this.i_isForOneOfConcernedPaths: bool = False
		
		def startDocument (a_this: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler") -> None:
			a_this.i_writer.write (GeneralConstantsConstantsGroup.c_lineFormat.format (XmlExpressionsConstantsGroup.c_xml1_0Declaration))
		
		def endDocument (a_this: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler") -> None:
			None
		
		def startElement (a_this: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler", a_elementName: str, a_attributes: AttributesImpl) -> None:
			try:
				l_pathString: Optional [str] = a_attributes.getValue (InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_pathAttributeName)
				l_path: Path = Path (l_pathString)
				if a_this.i_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_addModeName:
					if a_this.i_concernedPathToIsFoundMap.get (l_path) is not None:
						a_this.i_isForOneOfConcernedPaths = True
						a_this.i_concernedPathToIsFoundMap [l_path] = True 
				else:
					l_concernedPath: Optional [Path] = None
					l_isFound: Optional [bool] = None
					# There is only one path in it.
					for l_concernedPath, l_isFound in a_this.i_concernedPathToIsFoundMap.items ():
						None
					if l_path == l_concernedPath or l_path.match ("{0:s}{1:s}{2:s}".format (str (l_concernedPath), GeneralConstantsConstantsGroup.c_linuxDirectoriesDelimiter, GeneralConstantsConstantsGroup.c_doubleAsterisks)):
						a_this.i_isForOneOfConcernedPaths = True
			except (KeyError) as l_exception:
				None
			if not a_this.i_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_removeModeName or not a_this.i_isForOneOfConcernedPaths:
				a_this.i_writer.write (XmlDatumHandler.getElementOpenString (a_elementName, a_attributes))
		
		def endElement (a_this: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler", a_elementName: str) -> None:
			if a_elementName == InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_rootElementName and a_this.i_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_addModeName:
				l_path: Optional [Path] = None
				l_isFound: Optional [bool] = None
				for l_path, l_isFound in a_this.i_concernedPathToIsFoundMap.items ():
					if not l_isFound:
						l_mediaTypeName: Optional [str] = None
						if not l_path.match (InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_pythonModuleFileGlobExpression):
							l_mediaTypeName = InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_binaryMediaTypeName
						else:
							l_mediaTypeName = InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_emptyMediaTypeName
						l_attributes: AttributesImpl = AttributesImpl ({InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_pathAttributeName: str (l_path), InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_mediaTypeAttributeName: l_mediaTypeName})
						a_this.i_writer.write (" {0:s}".format (GeneralConstantsConstantsGroup.c_lineFormat).format (XmlDatumHandler.getElementOpenString (InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_filePathElementName, l_attributes)))
						a_this.i_writer.write (" {0:s}".format (GeneralConstantsConstantsGroup.c_lineFormat).format (XmlDatumHandler.getElementCloseString (InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler.c_filePathElementName)))
			if not a_this.i_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_removeModeName or not a_this.i_isForOneOfConcernedPaths:
				a_this.i_writer.write (XmlDatumHandler.getElementCloseString (a_elementName))
			a_this.i_isForOneOfConcernedPaths = False
		
		def characters (a_this: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler", a_contents: str) -> None:
			if not a_this.i_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_removeModeName or not a_this.i_isForOneOfConcernedPaths:
				a_this.i_writer.write (a_contents)
	
	"""
	arguments:
	1 -> the file ingredients base directory path string
	2 -> the mode name: 'add' or 'remove'
	3 -> the target path string: if the mode name is 'remove' and this is empty, the 'Scripts' directory will be removed.
	"""
	@staticmethod
	def main (a_arguments: List [str]) -> None:
		if len (a_arguments) != 4:
			Publisher.logErrorInformation ("The arguments have to be these:\n1) the file ingredients base directory path string\n2) the mode name: 'add' or 'remove'\n3) the target path string: if the mode name is 'remove' and this is empty, the 'Scripts' directory will be removed.\n")
			exit GeneralConstantsConstantsGroup.c_errorResult
		l_fileIngredientsBaseDirectoryPath: Path = Path (a_arguments [1])
		l_modeName: str = a_arguments [2]
		if l_modeName != InDocumentPythonMacrosRegistrarConsoleProgram.c_addModeName and l_modeName != InDocumentPythonMacrosRegistrarConsoleProgram.c_removeModeName:
			Publisher.logErrorInformation ("The mode name has to be 'add' or 'remove'\n")
			exit GeneralConstantsConstantsGroup.c_errorResult
		
		l_targetPath: Path = Path (a_arguments [3])
		if l_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_addModeName and l_targetPath == GeneralConstantsConstantsGroup.c_emptyString:
			Publisher.logErrorInformation ("The target path string cannot be empty for the 'add' mode\n")
			exit GeneralConstantsConstantsGroup.c_errorResult
		l_manifestFilePath: Path = l_fileIngredientsBaseDirectoryPath.joinpath (InDocumentPythonMacrosRegistrarConsoleProgram.c_manifestFileRelativePath)
		l_manifestModifiedFilePath: Path = l_fileIngredientsBaseDirectoryPath.joinpath (Path (GeneralConstantsConstantsGroup.c_fileNameFormat.format (str (InDocumentPythonMacrosRegistrarConsoleProgram.c_manifestFileRelativePath), FileNameSuffixesConstantsGroup.c_modifiedFileNameSuffix)))
		l_concernedPathToIsFoundMap: "OrderedDict [Path, bool]" = OrderedDict ()
		if l_modeName == InDocumentPythonMacrosRegistrarConsoleProgram.c_addModeName:
			l_concernedPathToIsFoundMap.update ({InDocumentPythonMacrosRegistrarConsoleProgram.c_pythonBaseDirectoryRelativePath.parent: False, InDocumentPythonMacrosRegistrarConsoleProgram.c_pythonBaseDirectoryRelativePath: False})
			l_path: Optional [Path] = None
			for l_path in reversed (l_targetPath.parents):
				if l_path != GeneralConstantsConstantsGroup.c_currentDirectoryPath:
					l_concernedPathToIsFoundMap.update ({InDocumentPythonMacrosRegistrarConsoleProgram.c_pythonBaseDirectoryRelativePath.joinpath (l_path): False})
		if l_targetPath != GeneralConstantsConstantsGroup.c_emptyString:
			l_concernedPathToIsFoundMap.update ({InDocumentPythonMacrosRegistrarConsoleProgram.c_pythonBaseDirectoryRelativePath.joinpath (l_targetPath): False})
		else:
			l_concernedPathToIsFoundMap.update ({InDocumentPythonMacrosRegistrarConsoleProgram.c_scriptsBaseDirectoryRelativePath: False})	
		l_saxParser: XMLReader = xml.sax.make_parser ( [DefaultValuesConstantsGroup.c_saxParserModuleName])
		l_saxParser.setFeature (xml.sax.handler.feature_namespaces, False)
		l_manifestFileReader: Optional [TextIO] = None
		l_manifestFileWriter: Optional [TextIO] = None
		try:
			l_manifestFileReader = cast (TextIO, open (l_manifestFilePath, FileOpenModeNamesConstantsGroup.c_readModeName))
			l_manifestFileWriter = cast (TextIO, open (l_manifestModifiedFilePath, FileOpenModeNamesConstantsGroup.c_eraseAndWriteModeName))
			l_manifestDatumParseEventsHandler: "InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler" = InDocumentPythonMacrosRegistrarConsoleProgram.ManifestDatumParseEventsHandler (l_modeName, l_concernedPathToIsFoundMap, l_manifestFileWriter)
			l_saxParser.setContentHandler (l_manifestDatumParseEventsHandler)
			l_saxParser.parse (l_manifestFileReader)
			os.remove (l_manifestFilePath)
			shutil.move (str (l_manifestModifiedFilePath), str (l_manifestFilePath))
		except (Exception) as l_exception:
			Publisher.logErrorInformation (l_exception)
			if l_manifestFileWriter is not None:
				l_manifestFileWriter.close ()
			if l_manifestFileReader is not None:
				l_manifestFileReader.close ()

if __name__ == GeneralConstantsConstantsGroup.c_pythonMainModuleName:
	InDocumentPythonMacrosRegistrarConsoleProgram.main (sys.argv)


theBiasPlanet/coreUtilities/constantsGroups/DefaultValuesConstantsGroup.py

@Python Source Code

class DefaultValuesConstantsGroup:
	~
	c_saxParserModuleName: str = "xml.sax.expatreader"


theBiasPlanet/coreUtilities/constantsGroups/FileNameSuffixesConstantsGroup.py

@Python Source Code

class FileNameSuffixesConstantsGroup:
	~
	c_pythonModuleFileNameSuffix: str = "py"
	~
	c_modifiedFileNameSuffix: str = "modified"


theBiasPlanet/coreUtilities/constantsGroups/FileOpenModeNamesConstantsGroup.py

@Python Source Code

class FileOpenModeNamesConstantsGroup:
	c_readModeName: str = "r"
	c_eraseAndWriteModeName: str = "w"
	~


theBiasPlanet/coreUtilities/constantsGroups/GeneralConstantsConstantsGroup.py

@Python Source Code
from pathlib import Path
import sys
from collections import OrderedDict
from theBiasPlanet.coreUtilities.constantsGroups.FileNameSuffixesConstantsGroup import FileNameSuffixesConstantsGroup

class GeneralConstantsConstantsGroup:
	~
	c_errorResult: int = -1
	~
	c_emptyString: str = ""
	~
	c_doubleAsterisks: str = "{0:s}{0:s}".format (c_asteriskCharacter)
	~
	c_linuxDirectoriesDelimiter: str = '/' # char
	~
	c_currentDirectoryPath: Path = Path (".")
	~
	c_fileNameFormat: str = "{{:s}}{:s}{{:s}}".format (c_fileNameElementsDelimiter)
	c_lineFormat: str = "{{:s}}{:s}".format (c_newLineCharacter)
	~
	c_pythonMainModuleName: str = "__main__"
	~


theBiasPlanet/coreUtilities/constantsGroups/XmlExpressionsConstantsGroup.py

@Python Source Code

class XmlExpressionsConstantsGroup:
	c_xml1_0Declaration: str = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>"
	~


'theBiasPlanet/coreUtilities/messagingHandling/Publisher.py' is totally omitted because the used method just writes logs.

theBiasPlanet/coreUtilities/xmlDataHandling/XmlDatumHandler.py

@Python Source Code
from typing import List
from typing import Optional
import xml.sax.saxutils
from xml.sax.xmlreader import AttributesImpl
from theBiasPlanet.coreUtilities.constantsGroups.GeneralConstantsConstantsGroup import GeneralConstantsConstantsGroup

class XmlDatumHandler:
	@staticmethod
	def getElementOpenString (a_elementName: str, a_attributes: AttributesImpl) -> str:
		l_attributeStrings: List [str] = []
		l_attributeName: Optional [str] = None
		for l_attributeName in a_attributes.getNames ():
			l_attributeStrings.append ("{0:s}={1:s}".format (l_attributeName, xml.sax.saxutils.quoteattr (a_attributes.getValue (l_attributeName))))
		l_attributesString = " ".join (l_attributeStrings)
		return GeneralConstantsConstantsGroup.c_quotedByAngleBracketsFormat.format ("{0:s} {1:s}".format (a_elementName, l_attributesString))
	
	@staticmethod
	def getElementCloseString (a_elementName: str) -> str:
		return GeneralConstantsConstantsGroup.c_quotedByAngleBracketsFormat.format ("/{0:s}".format (a_elementName))


Objector 49B
. . . Um? How can I use it?

Hypothesizer 7
In the above shell script, you can just replace 'vim "META-INF/manifest.xml"' with 'bash -c "export PYTHONPATH=\"${PYTHONPATH}:/home/%user name%/myData/development/inDocumentPythonMacrosRegistrar/target:/home/%user name%/myData/development/coreUtilities/target\"; python3 -m theBiasPlanet.inDocumentPythonMacrosRegistrar.programs.InDocumentPythonMacrosRegistrarConsoleProgram \".\" \"${l_modeName}\" \"./${l_macroPackagePath}/${l_macroFileName}\""', while, of course, the Python modules paths have to be configured appropriately.


3: Testing


Hypothesizer 7
Let us open the file and see that the Python macro is indeed recognized by LibreOffice or Apache OpenOffice, by selecting 'Tools' -> 'Macros' -> 'Run Macro...' -> '%the file name%' -> 'theBiasPlanet' -> 'pythonEnvironmentChecker' -> 'macros' -> 'PythonEnvironmentChecker' -> 'checkPythonEnvironment'.

Objector 49A
. . . Oh, it's there! Does it run?

Hypothesizer 7
If the macro is all right, of course.


References


<The previous article in this series | The table of contents of this series | The next article in this series>