File: scripting.py

package info (click to toggle)
gavodachs 2.3%2Bdfsg-3
  • links: PTS, VCS
  • area: main
  • in suites: bullseye
  • size: 7,260 kB
  • sloc: python: 58,359; xml: 8,882; javascript: 3,453; ansic: 661; sh: 158; makefile: 22
file content (282 lines) | stat: -rw-r--r-- 9,060 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
"""
Support code for attaching scripts to objects.

Scripts can be either in python or in SQL.  They always live on
make instances.  For details, see Scripting in the reference
documentation.
"""

#c Copyright 2008-2020, the GAVO project
#c
#c This program is free software, covered by the GNU GPL.  See the
#c COPYING file in the source distribution.


from gavo import base
from gavo import utils
from gavo.base import sqlsupport
from gavo.rscdef import rmkfuncs
from gavo.utils.parsetricks import (
	OneOrMore, ZeroOrMore, QuotedString, Forward,
	SkipTo, StringEnd, Regex, Suppress,
	Literal, pyparsingWhitechars)



class Error(base.Error):
	pass


def _getSQLScriptGrammar():
	"""returns a pyparsing ParserElement that splits SQL scripts into
	individual commands.

	The rules are: Statements are separated by semicolons, empty statements
	are allowed.
	"""
	with pyparsingWhitechars(" \t"):
		atom = Forward()
		atom.setName("Atom")

		sqlComment = Literal("--")+SkipTo("\n", include=True)
		cStyleComment = Literal("/*")+SkipTo("*/", include=True)
		comment = sqlComment | cStyleComment
		lineEnd = Literal("\n")

		simpleStr = QuotedString(quoteChar="'", escChar="\\", 
			multiline=True, unquoteResults=False)
		quotedId = QuotedString(quoteChar='"', escChar="\\", unquoteResults=False)
		dollarQuoted = Regex(r"(?s)\$(\w*)\$.*?\$\1\$")
		dollarQuoted.setName("dollarQuoted")
		# well, quotedId is not exactly a string literal.  I hate it, and so
		# it's lumped in here.
		strLiteral = simpleStr | dollarQuoted | quotedId
		strLiteral.setName("strLiteral")

		other = Regex("[^;'\"$]+")
		other.setName("other")

		literalDollar = Literal("$") + ~ Literal("$")
		statementEnd = ( Literal(';') + ZeroOrMore(lineEnd) | StringEnd() )

		atom <<  ( Suppress(comment) | other | strLiteral | literalDollar )
		statement = OneOrMore(atom) + Suppress( statementEnd )
		statement.setName("statement")
		statement.setParseAction(lambda s, p, toks: " ".join(toks))

		script = OneOrMore( statement ) + StringEnd()
		script.setName("script")
		script.setParseAction(lambda s, p, toks: [t for t in toks.asList()
			if str(t).strip()])

		if False:
			atom.setDebug(True)
			comment.setDebug(True)
			other.setDebug(True)
			strLiteral.setDebug(True)
			statement.setDebug(True)
			statementEnd.setDebug(True)
			dollarQuoted.setDebug(True)
			literalDollar.setDebug(True)
		return script


getSQLScriptGrammar = utils.CachedGetter(_getSQLScriptGrammar)


class ScriptRunner(object):
	"""An object encapsulating the preparation and execution of
	scripts.

	They are constructed with instances of Script below and have
	a method ``run(dbTable, **kwargs)``.

	You probably should not override ``__init__`` but instead override
	``_prepare(script)`` which is called by ``__init__``.
	"""
	def __init__(self, script):
		self.name, self.notify = script.name, script.notify
		self.pos = script.getSourcePosition()
		self._prepare(script)
	
	def _prepare(self, script):
		raise ValueError("Cannot instantate plain ScriptRunners")


class SQLScriptRunner(ScriptRunner):
	"""A runner for SQL scripts.

	These will always use the table's querier to execute the statements.

	Keyword arguments to run are ignored.
	"""
	def _prepare(self, script):
		self.statements = utils.pyparseString(getSQLScriptGrammar(), 
			script.getSource())
	
	def run(self, dbTable, **kwargs):
		try:
			for statement in self.statements:
				dbTable.connection.execute(
					dbTable.expand(statement.replace("%", "%%")))
		except Exception as msg:
			raise base.ui.logOldExc(
				base.StructureError(
					"Execution of SQL script %s failed: %s"%(self.name, msg),
					self.pos))


class ACSQLScriptRunner(SQLScriptRunner):
	"""A runner for "autocommitted" SQL scripts.

	These are like SQLScriptRunners, except that for every statement,
	a savepoint is created, and for SQL errors, the savepoint is restored
	(in other words ACSQL scripts turn SQL errors into warnings).
	"""
	def run(self, dbTable, **kwargs):
		conn = dbTable.connection
		for statement in self.statements:
			try:
				conn.execute("SAVEPOINT beforeStatement")
				try:
					conn.execute(statement.replace("%", "%%"))
				except sqlsupport.DBError as msg:
					conn.execute("ROLLBACK TO SAVEPOINT beforeStatement")
					base.ui.notifyError("Ignored error during script execution: %s"%
						msg)
			finally:
				conn.execute("RELEASE SAVEPOINT beforeStatement")


class PythonScriptRunner(ScriptRunner):
	"""A runner for python scripts.

	The scripts can access the current table as table (and thus run
	SQL statements through table.connection.execute(query, pars)).

	Additional keyword arguments are available under their names.

	You are in the namespace of usual procApps (like procs, rowgens, and
	the like).
	"""
	def __init__(self, script):
		# I need to memorize the script as I may need to recompile
		# it if there's special arguments (yikes!)
		self.code = ("def scriptFun(table, **kwargs):\n"+
			utils.fixIndentation(script.getSource(), "      ")+"\n")
		ScriptRunner.__init__(self, script)

	def _compile(self, moreNames={}):
		return rmkfuncs.makeProc("scriptFun", self.code, "", self,
			**moreNames)

	def _prepare(self, script, moreNames={}):
		self.scriptFun = self._compile()
	
	def run(self, dbTable, **kwargs):
# I want the names from kwargs to be visible as such in scriptFun -- if
# given.  Since I do not want to manipulate func_globals, the only
# way I can see to do this is to compile the script.  I don't think
# this is going to be a major performance issue.
		try:
			if kwargs:
				func = self._compile(kwargs)
			else:
				func = self.scriptFun
			func(dbTable, **kwargs)
		except Exception as msg:
			raise base.ui.logOldExc(
				base.StructureError(
					"Execution of python script %s failed: %s"%(self.name, msg),
					self.pos))


RUNNER_CLASSES = {
	"SQL": SQLScriptRunner,
	"python": PythonScriptRunner,
	"AC_SQL": ACSQLScriptRunner,
}

class Script(base.Structure, base.RestrictionMixin):
	"""A script, i.e., some executable item within a resource descriptor.

	The content of scripts is given by their type -- usually, they are
	either python scripts or SQL with special rules for breaking the
	script into individual statements (which are basically like python's).

	The special language AC_SQL is like SQL, but execution errors are
	ignored.  This is not what you want for most data RDs (it's intended
	for housekeeping scripts).

	See `Scripting`_.
	"""
	name_ = "script"
	typeDesc_ = "Embedded executable code with a type definition"

	_lang = base.EnumeratedUnicodeAttribute("lang", default=base.Undefined,
		description="Language of the script.", 
		validValues=RUNNER_CLASSES.keys(), copyable=True)
	_type = base.EnumeratedUnicodeAttribute("type", default=base.Undefined,
		description="Point of time at which script is to run (not all"
		" script types are allowed on all elements).",
		validValues=["preImport", "newSource", "preIndex", "preCreation",
			"postCreation", "afterMeta",
			"beforeDrop", "sourceDone"], copyable=True)
	_name = base.UnicodeAttribute("name", default="anonymous",
		description="A human-consumable designation of the script.",
		copyable=True)
	_notify = base.BooleanAttribute("notify", default=True,
		description="Send out a notification when running this"
			" script.", copyable=True)
	_content = base.DataContent(copyable=True, description="The script body.")
	_original = base.OriginalAttribute()

	def getSource(self):
		"""returns the content with all macros expanded.
		"""
		return self.parent.getExpander().expand(self.content_)

	def validate(self):
		if self.parent and self.type not in self.parent.acceptedScriptTypes:
			raise base.StructureError("Invalid script type %s for %s elements"%(
				self.type, self.parent.name_))

		self._validateNext(Script)


class ScriptingMixin(object):
	"""A mixin that gives objects a getRunner method and a script attribute.

	The getRunner() method returns a callable that takes the current table
	(we expect db tables, really), the phase and possibly further keyword
	arguments, as appropriate for the phase.

	Objects mixing this in must define an acceptedScriptTypes attribute
	containing a set of script types they support.  Any other script type
	will be rejected.

	Objects mixing this in must also support define a method
	getExpander() returning an object mixin in a MacroPackage.
	"""
	acceptedScriptTypes = {}

	_scripts = base.StructListAttribute("scripts", childFactory=Script,
		description="Code snippets attached to this object.  See Scripting_ .",
		copyable=False)

	def getRunner(self):
		if not hasattr(self, "_runScriptsCache"):
			runnersByPhase = {}
			for rawScript in self.scripts:
				runner = RUNNER_CLASSES[rawScript.lang](rawScript)
				runnersByPhase.setdefault(rawScript.type, []).append(runner)
				
			def runScripts(table, phase, **kwargs):
				for runner in runnersByPhase.get(phase, []):
					if runner.notify:
						base.ui.notifyScriptRunning(runner, self)
					runner.run(table, **kwargs)

			self._runScriptsCache = runScripts

		return self._runScriptsCache