| #!/usr/bin/python |
| # Copyright 2014 Google Inc. All Rights Reserved. |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); |
| # you may not use this file except in compliance with the License. |
| # You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| # TR-069 has mandatory attribute names that don't comply with policy |
| # pylint:disable=invalid-name |
| # |
| """Support for 'experiments' which can be used for A/B testing.""" |
| |
| import datetime |
| import errno |
| import os |
| import re |
| import traceback |
| import google3 |
| import cwmptypes |
| import handle |
| import helpers |
| import mainloop |
| import x_catawampus_tr181_2_0 |
| |
| REGDIR = helpers.Path('/tmp/experiments') |
| ACTIVEDIR = helpers.Path('/config/experiments') |
| |
| BASE = x_catawampus_tr181_2_0.X_CATAWAMPUS_ORG_Device_v2_0 |
| CATABASE = BASE.Device.X_CATAWAMPUS_ORG |
| |
| MAX_FILE_NUMBER = 256 |
| MAX_EXPERIMENT_NAME_LENGTH = 128 |
| |
| # The global list of all experiments registered with @Experiment |
| registered = {} |
| |
| |
| def _ListIfExists(dirname): |
| try: |
| return os.listdir(dirname) |
| except OSError as e: |
| if e.errno == errno.ENOENT: |
| return [] |
| raise |
| |
| |
| def _GetSystemExperiments(dirname, suffix): |
| out = set() |
| for name in _ListIfExists(dirname): |
| if name.endswith(suffix): |
| out.add(name[:-len(suffix)]) |
| return out |
| |
| |
| def Experiment(fn): |
| """Wrapper function for registering new experiments. |
| |
| For example, to register an experiment called MyExperiment: |
| @Experiment |
| def MyExperiment(roothandle): |
| return [('My.Test.Param', 7), |
| ('My.Test.Param2', roothandle.obj.Dynamic.Test.Value), |
| ('My.Test.Param3', random.random())] |
| |
| Args: |
| fn: the function to wrap. |
| Returns: |
| fn, after registering fn with the experiment framework. |
| """ |
| name = fn.__name__ |
| print 'Registering experiment %r' % name |
| registered[fn.__name__] = fn |
| return fn |
| |
| |
| class Experiments(CATABASE.Experiments): |
| """Implementation of X_CATAWAMPUS-ORG_CATAWAMPUS.Experiments object. |
| |
| This object is part of the TR-069 data model and allows you to activate |
| and deactivate experiments by setting the 'Requested' member. |
| """ |
| |
| def __init__(self, roothandle): |
| super(Experiments, self).__init__() |
| self.force_values = {} |
| self.saved_values = {} |
| self.roothandle = roothandle |
| assert hasattr(roothandle, 'inner') |
| self.active = [] |
| self.loop = mainloop.MainLoop() |
| # Read initial value of requested system experiments from the filesystem |
| try: |
| # This will trigger Triggered(), but can't be allowed to throw an |
| # exception at startup time, so catch it if something goes wrong. |
| req = ((_GetSystemExperiments(ACTIVEDIR, '.active') | |
| _GetSystemExperiments(ACTIVEDIR, '.requested')) - |
| _GetSystemExperiments(ACTIVEDIR, '.unrequested') - |
| set(registered.keys())) |
| self.Requested = ','.join(sorted(req)) |
| except (IOError, OSError) as e: |
| traceback.print_exc() |
| print "Experiments: can't init self.Requested: %r" % e |
| self._Periodic() |
| |
| @property |
| def Available(self): |
| avail = set(registered.keys()) |
| sysavail = _GetSystemExperiments(REGDIR, '.available') |
| return ','.join(sorted(avail | sysavail)) |
| |
| # TODO(apenwarr): make experiments persist across reboots. |
| # Without such a feature, we won't be able to make experiments that |
| # affect the early boot process (such as driver loading). |
| Requested = cwmptypes.TriggerString('') |
| |
| @Requested.validator |
| def Requested(self, v): |
| # Note: unlike Available, order of the Requested object is significant, |
| # so we shouldn't sort it. |
| return ','.join(i.strip() for i in v.split(',')) |
| |
| @property |
| def Active(self): |
| # Note: unlike Available, order of the Active object is significant, |
| # so we shouldn't sort it. (But system experiments are unordered, so |
| # we sort those.) |
| available = self.Available |
| active = [name for name, unused_obj in self.active] |
| sysactive = set() |
| for name in _ListIfExists(ACTIVEDIR): |
| if name.endswith('.active'): |
| exp = name[:-7] |
| if exp in available: |
| sysactive.add(exp) |
| return ','.join(active + sorted(sysactive)) |
| |
| def _AnnounceActives(self): |
| print 'Experiments now active: %s' % self.Active |
| |
| def _Periodic(self): |
| self.loop.ioloop.add_timeout(datetime.timedelta(seconds=29), |
| self._Periodic) |
| self._AnnounceActives() |
| |
| def Triggered(self): |
| """Triggered whenever Requested is changed.""" |
| |
| # Flush old experiments |
| keys = self.saved_values.keys() |
| lookups = self.roothandle.inner.LookupExports(keys) |
| for k, (h, param) in zip(keys, lookups): |
| h.SetExportParam(param, self.saved_values[k]) |
| del self.active[:] |
| self.force_values.clear() |
| self.saved_values.clear() |
| |
| # TODO(apenwarr): use transactions like api.SetParameterValues() does. |
| print 'Experiments requested: %r' % self.Requested |
| sysactive = _GetSystemExperiments(ACTIVEDIR, '.active') |
| sysrequested = _GetSystemExperiments(ACTIVEDIR, '.requested') |
| |
| reqstr = self.Requested |
| requested = reqstr.split(',') if reqstr else [] |
| for name in requested: |
| expfunc = registered.get(name) |
| if not expfunc: |
| if re.findall(r'[^A-Za-z0-9_-]', name): |
| print 'Invalid characters in experiment %r' % name |
| elif len(name) > MAX_EXPERIMENT_NAME_LENGTH: |
| print ('Experiment %r length %r is greater than the maximum ' |
| 'allowed length %r' % (name, len(name), |
| MAX_EXPERIMENT_NAME_LENGTH)) |
| elif len(os.listdir(ACTIVEDIR)) >= MAX_FILE_NUMBER: |
| print ('Unable to request system experiment, too many files in ' |
| '%r' % ACTIVEDIR) |
| else: |
| print 'Requesting system experiment %r' % name |
| helpers.Unlink(os.path.join(ACTIVEDIR, name + '.unrequested')) |
| if not os.path.exists(os.path.join(ACTIVEDIR, |
| name + '.active')): |
| helpers.WriteFileAtomic( |
| os.path.join(ACTIVEDIR, name + '.requested'), '') |
| else: |
| print 'Applying experiment %r' % name |
| forces = list(expfunc(self.roothandle)) |
| self.force_values.update(forces) |
| keys = [f[0] for f in forces] |
| lookups = list(self.roothandle.inner.LookupExports(keys)) |
| for (k, _), (h, param) in zip(forces, lookups): |
| if k not in self.saved_values: |
| print ' Saving pre-experiment value for %r' % k |
| self.saved_values[k] = h.GetExport(param) |
| for (k, v), (h, param) in zip(forces, lookups): |
| print ' Writing new value for %r = %r' % (k, v) |
| h.SetExportParam(param, v) |
| self.active.append((name, forces)) |
| |
| for name in sysactive | sysrequested: |
| if name not in requested: |
| print 'Unrequesting system experiment %r' % name |
| helpers.Unlink(os.path.join(ACTIVEDIR, name + '.requested')) |
| if os.path.exists(os.path.join(ACTIVEDIR, name + '.active')): |
| helpers.WriteFileAtomic( |
| os.path.join(ACTIVEDIR, name + '.unrequested'), '') |
| |
| self._AnnounceActives() |
| |
| |
| class ExperimentHandle(handle.Handle): |
| """A variant of handle.Handle that prevents overwriting experiment values. |
| |
| If an experiment is active and you use SetExportParam() to write to one of |
| the affected variables, the write will be captured and saved for later |
| (ie. if the experiment is stopped), at which time the value will be written |
| to the data model. In the meantime, the experimental setting is the one |
| that is used. |
| |
| Writing directly to the data model (ie. bypassing the handle altogether) |
| still goes through. That's important so that objects can still update |
| their status and internal settings, etc. |
| """ |
| |
| def __init__(self, obj, basename='', roothandle=None): |
| super(ExperimentHandle, self).__init__(obj, basename=basename, |
| roothandle=roothandle) |
| self.root_experiments = None |
| |
| @property |
| def inner(self): |
| if self.roothandle: |
| return self.roothandle.inner.Sub(self.basename) |
| elif self.basename: |
| return handle.Handle(self.obj).Sub(self.basename) |
| else: |
| return handle.Handle(self.obj) |
| |
| @property |
| def experiments(self): |
| if self.roothandle: |
| return self.roothandle.experiments |
| else: |
| return self.root_experiments |
| |
| def SetExportParam(self, name, value): |
| if self.basename: |
| fullname = self.basename + '.' + name |
| else: |
| fullname = name |
| ex = self.experiments |
| if fullname in ex.saved_values: |
| ex.saved_values[fullname] = value |
| return self.inner.FindExport(name)[0].obj |
| else: |
| return self.inner.SetExportParam(name, value) |