diff --git a/charm-helpers-hooks.yaml b/charm-helpers-hooks.yaml
index 6da8c15..b1790f2 100644
--- a/charm-helpers-hooks.yaml
+++ b/charm-helpers-hooks.yaml
@@ -2,6 +2,7 @@ branch: lp:charm-helpers
destination: hooks/charmhelpers
include:
- core
+ - cli
- fetch
- contrib.openstack|inc=*
- contrib.python.packages
diff --git a/hooks/charmhelpers/cli/__init__.py b/hooks/charmhelpers/cli/__init__.py
new file mode 100644
index 0000000..7118daf
--- /dev/null
+++ b/hooks/charmhelpers/cli/__init__.py
@@ -0,0 +1,195 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+import inspect
+import argparse
+import sys
+
+from six.moves import zip
+
+from charmhelpers.core import unitdata
+
+
+class OutputFormatter(object):
+ def __init__(self, outfile=sys.stdout):
+ self.formats = (
+ "raw",
+ "json",
+ "py",
+ "yaml",
+ "csv",
+ "tab",
+ )
+ self.outfile = outfile
+
+ def add_arguments(self, argument_parser):
+ formatgroup = argument_parser.add_mutually_exclusive_group()
+ choices = self.supported_formats
+ formatgroup.add_argument("--format", metavar='FMT',
+ help="Select output format for returned data, "
+ "where FMT is one of: {}".format(choices),
+ choices=choices, default='raw')
+ for fmt in self.formats:
+ fmtfunc = getattr(self, fmt)
+ formatgroup.add_argument("-{}".format(fmt[0]),
+ "--{}".format(fmt), action='store_const',
+ const=fmt, dest='format',
+ help=fmtfunc.__doc__)
+
+ @property
+ def supported_formats(self):
+ return self.formats
+
+ def raw(self, output):
+ """Output data as raw string (default)"""
+ if isinstance(output, (list, tuple)):
+ output = '\n'.join(map(str, output))
+ self.outfile.write(str(output))
+
+ def py(self, output):
+ """Output data as a nicely-formatted python data structure"""
+ import pprint
+ pprint.pprint(output, stream=self.outfile)
+
+ def json(self, output):
+ """Output data in JSON format"""
+ import json
+ json.dump(output, self.outfile)
+
+ def yaml(self, output):
+ """Output data in YAML format"""
+ import yaml
+ yaml.safe_dump(output, self.outfile)
+
+ def csv(self, output):
+ """Output data as excel-compatible CSV"""
+ import csv
+ csvwriter = csv.writer(self.outfile)
+ csvwriter.writerows(output)
+
+ def tab(self, output):
+ """Output data in excel-compatible tab-delimited format"""
+ import csv
+ csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
+ csvwriter.writerows(output)
+
+ def format_output(self, output, fmt='raw'):
+ fmtfunc = getattr(self, fmt)
+ fmtfunc(output)
+
+
+class CommandLine(object):
+ argument_parser = None
+ subparsers = None
+ formatter = None
+ exit_code = 0
+
+ def __init__(self):
+ if not self.argument_parser:
+ self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
+ if not self.formatter:
+ self.formatter = OutputFormatter()
+ self.formatter.add_arguments(self.argument_parser)
+ if not self.subparsers:
+ self.subparsers = self.argument_parser.add_subparsers(help='Commands')
+
+ def subcommand(self, command_name=None):
+ """
+ Decorate a function as a subcommand. Use its arguments as the
+ command-line arguments"""
+ def wrapper(decorated):
+ cmd_name = command_name or decorated.__name__
+ subparser = self.subparsers.add_parser(cmd_name,
+ description=decorated.__doc__)
+ for args, kwargs in describe_arguments(decorated):
+ subparser.add_argument(*args, **kwargs)
+ subparser.set_defaults(func=decorated)
+ return decorated
+ return wrapper
+
+ def test_command(self, decorated):
+ """
+ Subcommand is a boolean test function, so bool return values should be
+ converted to a 0/1 exit code.
+ """
+ decorated._cli_test_command = True
+ return decorated
+
+ def no_output(self, decorated):
+ """
+ Subcommand is not expected to return a value, so don't print a spurious None.
+ """
+ decorated._cli_no_output = True
+ return decorated
+
+ def subcommand_builder(self, command_name, description=None):
+ """
+ Decorate a function that builds a subcommand. Builders should accept a
+ single argument (the subparser instance) and return the function to be
+ run as the command."""
+ def wrapper(decorated):
+ subparser = self.subparsers.add_parser(command_name)
+ func = decorated(subparser)
+ subparser.set_defaults(func=func)
+ subparser.description = description or func.__doc__
+ return wrapper
+
+ def run(self):
+ "Run cli, processing arguments and executing subcommands."
+ arguments = self.argument_parser.parse_args()
+ argspec = inspect.getargspec(arguments.func)
+ vargs = []
+ kwargs = {}
+ for arg in argspec.args:
+ vargs.append(getattr(arguments, arg))
+ if argspec.varargs:
+ vargs.extend(getattr(arguments, argspec.varargs))
+ if argspec.keywords:
+ for kwarg in argspec.keywords.items():
+ kwargs[kwarg] = getattr(arguments, kwarg)
+ output = arguments.func(*vargs, **kwargs)
+ if getattr(arguments.func, '_cli_test_command', False):
+ self.exit_code = 0 if output else 1
+ output = ''
+ if getattr(arguments.func, '_cli_no_output', False):
+ output = ''
+ self.formatter.format_output(output, arguments.format)
+ if unitdata._KV:
+ unitdata._KV.flush()
+
+
+cmdline = CommandLine()
+
+
+def describe_arguments(func):
+ """
+ Analyze a function's signature and return a data structure suitable for
+ passing in as arguments to an argparse parser's add_argument() method."""
+
+ argspec = inspect.getargspec(func)
+ # we should probably raise an exception somewhere if func includes **kwargs
+ if argspec.defaults:
+ positional_args = argspec.args[:-len(argspec.defaults)]
+ keyword_names = argspec.args[-len(argspec.defaults):]
+ for arg, default in zip(keyword_names, argspec.defaults):
+ yield ('--{}'.format(arg),), {'default': default}
+ else:
+ positional_args = argspec.args
+
+ for arg in positional_args:
+ yield (arg,), {}
+ if argspec.varargs:
+ yield (argspec.varargs,), {'nargs': '*'}
diff --git a/hooks/charmhelpers/cli/benchmark.py b/hooks/charmhelpers/cli/benchmark.py
new file mode 100644
index 0000000..b23c16c
--- /dev/null
+++ b/hooks/charmhelpers/cli/benchmark.py
@@ -0,0 +1,36 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+from . import cmdline
+from charmhelpers.contrib.benchmark import Benchmark
+
+
+@cmdline.subcommand(command_name='benchmark-start')
+def start():
+ Benchmark.start()
+
+
+@cmdline.subcommand(command_name='benchmark-finish')
+def finish():
+ Benchmark.finish()
+
+
+@cmdline.subcommand_builder('benchmark-composite', description="Set the benchmark composite score")
+def service(subparser):
+ subparser.add_argument("value", help="The composite score.")
+ subparser.add_argument("units", help="The units the composite score represents, i.e., 'reads/sec'.")
+ subparser.add_argument("direction", help="'asc' if a lower score is better, 'desc' if a higher score is better.")
+ return Benchmark.set_composite_score
diff --git a/hooks/charmhelpers/cli/commands.py b/hooks/charmhelpers/cli/commands.py
new file mode 100644
index 0000000..443ff05
--- /dev/null
+++ b/hooks/charmhelpers/cli/commands.py
@@ -0,0 +1,32 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+"""
+This module loads sub-modules into the python runtime so they can be
+discovered via the inspect module. In order to prevent flake8 from (rightfully)
+telling us these are unused modules, throw a ' # noqa' at the end of each import
+so that the warning is suppressed.
+"""
+
+from . import CommandLine # noqa
+
+"""
+Import the sub-modules which have decorated subcommands to register with chlp.
+"""
+import host # noqa
+import benchmark # noqa
+import unitdata # noqa
+from charmhelpers.core import hookenv # noqa
diff --git a/hooks/charmhelpers/cli/host.py b/hooks/charmhelpers/cli/host.py
new file mode 100644
index 0000000..58e78d6
--- /dev/null
+++ b/hooks/charmhelpers/cli/host.py
@@ -0,0 +1,31 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+from . import cmdline
+from charmhelpers.core import host
+
+
+@cmdline.subcommand()
+def mounts():
+ "List mounts"
+ return host.mounts()
+
+
+@cmdline.subcommand_builder('service', description="Control system services")
+def service(subparser):
+ subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
+ subparser.add_argument("service_name", help="Name of the service to control")
+ return host.service
diff --git a/hooks/charmhelpers/cli/unitdata.py b/hooks/charmhelpers/cli/unitdata.py
new file mode 100644
index 0000000..d1cd95b
--- /dev/null
+++ b/hooks/charmhelpers/cli/unitdata.py
@@ -0,0 +1,39 @@
+# Copyright 2014-2015 Canonical Limited.
+#
+# This file is part of charm-helpers.
+#
+# charm-helpers is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Lesser General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# charm-helpers is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Lesser General Public License for more details.
+#
+# You should have received a copy of the GNU Lesser General Public License
+# along with charm-helpers. If not, see .
+
+from . import cmdline
+from charmhelpers.core import unitdata
+
+
+@cmdline.subcommand_builder('unitdata', description="Store and retrieve data")
+def unitdata_cmd(subparser):
+ nested = subparser.add_subparsers()
+ get_cmd = nested.add_parser('get', help='Retrieve data')
+ get_cmd.add_argument('key', help='Key to retrieve the value of')
+ get_cmd.set_defaults(action='get', value=None)
+ set_cmd = nested.add_parser('set', help='Store data')
+ set_cmd.add_argument('key', help='Key to set')
+ set_cmd.add_argument('value', help='Value to store')
+ set_cmd.set_defaults(action='set')
+
+ def _unitdata_cmd(action, key, value):
+ if action == 'get':
+ return unitdata.kv().get(key)
+ elif action == 'set':
+ unitdata.kv().set(key, value)
+ unitdata.kv().flush()
+ return ''
+ return _unitdata_cmd
diff --git a/hooks/charmhelpers/contrib/openstack/context.py b/hooks/charmhelpers/contrib/openstack/context.py
index bbf4722..ab2ebac 100644
--- a/hooks/charmhelpers/contrib/openstack/context.py
+++ b/hooks/charmhelpers/contrib/openstack/context.py
@@ -1051,8 +1051,11 @@ class SubordinateConfigContext(OSContextGenerator):
:param config_file : Service's config file to query sections
:param interface : Subordinate interface to inspect
"""
- self.service = service
self.config_file = config_file
+ if isinstance(service, list):
+ self.services = service
+ else:
+ self.services = [service]
if isinstance(interface, list):
self.interfaces = interface
else:
@@ -1075,31 +1078,32 @@ class SubordinateConfigContext(OSContextGenerator):
'setting from %s' % rid, level=ERROR)
continue
- if self.service not in sub_config:
- log('Found subordinate_config on %s but it contained'
- 'nothing for %s service' % (rid, self.service),
- level=INFO)
- continue
+ for service in self.services:
+ if service not in sub_config:
+ log('Found subordinate_config on %s but it contained'
+ 'nothing for %s service' % (rid, service),
+ level=INFO)
+ continue
- sub_config = sub_config[self.service]
- if self.config_file not in sub_config:
- log('Found subordinate_config on %s but it contained'
- 'nothing for %s' % (rid, self.config_file),
- level=INFO)
- continue
+ sub_config = sub_config[service]
+ if self.config_file not in sub_config:
+ log('Found subordinate_config on %s but it contained'
+ 'nothing for %s' % (rid, self.config_file),
+ level=INFO)
+ continue
- sub_config = sub_config[self.config_file]
- for k, v in six.iteritems(sub_config):
- if k == 'sections':
- for section, config_list in six.iteritems(v):
- log("adding section '%s'" % (section),
- level=DEBUG)
- if ctxt[k].get(section):
- ctxt[k][section].extend(config_list)
- else:
- ctxt[k][section] = config_list
- else:
- ctxt[k] = v
+ sub_config = sub_config[self.config_file]
+ for k, v in six.iteritems(sub_config):
+ if k == 'sections':
+ for section, config_list in six.iteritems(v):
+ log("adding section '%s'" % (section),
+ level=DEBUG)
+ if ctxt[k].get(section):
+ ctxt[k][section].extend(config_list)
+ else:
+ ctxt[k][section] = config_list
+ else:
+ ctxt[k] = v
log("%d section(s) found" % (len(ctxt['sections'])), level=DEBUG)
return ctxt
diff --git a/hooks/charmhelpers/core/hookenv.py b/hooks/charmhelpers/core/hookenv.py
index 15b09d1..6e4fb68 100644
--- a/hooks/charmhelpers/core/hookenv.py
+++ b/hooks/charmhelpers/core/hookenv.py
@@ -34,6 +34,8 @@ import errno
import tempfile
from subprocess import CalledProcessError
+from charmhelpers.cli import cmdline
+
import six
if not six.PY3:
from UserDict import UserDict
@@ -173,9 +175,20 @@ def relation_type():
return os.environ.get('JUJU_RELATION', None)
-def relation_id():
- """The relation ID for the current relation hook"""
- return os.environ.get('JUJU_RELATION_ID', None)
+@cmdline.subcommand()
+@cached
+def relation_id(relation_name=None, service_or_unit=None):
+ """The relation ID for the current or a specified relation"""
+ if not relation_name and not service_or_unit:
+ return os.environ.get('JUJU_RELATION_ID', None)
+ elif relation_name and service_or_unit:
+ service_name = service_or_unit.split('/')[0]
+ for relid in relation_ids(relation_name):
+ remote_service = remote_service_name(relid)
+ if remote_service == service_name:
+ return relid
+ else:
+ raise ValueError('Must specify neither or both of relation_name and service_or_unit')
def local_unit():
@@ -188,14 +201,27 @@ def remote_unit():
return os.environ.get('JUJU_REMOTE_UNIT', None)
+@cmdline.subcommand()
def service_name():
"""The name service group this unit belongs to"""
return local_unit().split('/')[0]
+@cmdline.subcommand()
+@cached
+def remote_service_name(relid=None):
+ """The remote service name for a given relation-id (or the current relation)"""
+ if relid is None:
+ unit = remote_unit()
+ else:
+ units = related_units(relid)
+ unit = units[0] if units else None
+ return unit.split('/')[0] if unit else None
+
+
def hook_name():
"""The name of the currently executing hook"""
- return os.path.basename(sys.argv[0])
+ return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
class Config(dict):
@@ -468,6 +494,63 @@ def relation_types():
return rel_types
+@cached
+def relation_to_interface(relation_name):
+ """
+ Given the name of a relation, return the interface that relation uses.
+
+ :returns: The interface name, or ``None``.
+ """
+ return relation_to_role_and_interface(relation_name)[1]
+
+
+@cached
+def relation_to_role_and_interface(relation_name):
+ """
+ Given the name of a relation, return the role and the name of the interface
+ that relation uses (where role is one of ``provides``, ``requires``, or ``peer``).
+
+ :returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
+ """
+ _metadata = metadata()
+ for role in ('provides', 'requires', 'peer'):
+ interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
+ if interface:
+ return role, interface
+ return None, None
+
+
+@cached
+def role_and_interface_to_relations(role, interface_name):
+ """
+ Given a role and interface name, return a list of relation names for the
+ current charm that use that interface under that role (where role is one
+ of ``provides``, ``requires``, or ``peer``).
+
+ :returns: A list of relation names.
+ """
+ _metadata = metadata()
+ results = []
+ for relation_name, relation in _metadata.get(role, {}).items():
+ if relation['interface'] == interface_name:
+ results.append(relation_name)
+ return results
+
+
+@cached
+def interface_to_relations(interface_name):
+ """
+ Given an interface, return a list of relation names for the current
+ charm that use that interface.
+
+ :returns: A list of relation names.
+ """
+ results = []
+ for role in ('provides', 'requires', 'peer'):
+ results.extend(role_and_interface_to_relations(role, interface_name))
+ return results
+
+
@cached
def charm_name():
"""Get the name of the current charm as is specified on metadata.yaml"""
@@ -644,6 +727,21 @@ def action_fail(message):
subprocess.check_call(['action-fail', message])
+def action_name():
+ """Get the name of the currently executing action."""
+ return os.environ.get('JUJU_ACTION_NAME')
+
+
+def action_uuid():
+ """Get the UUID of the currently executing action."""
+ return os.environ.get('JUJU_ACTION_UUID')
+
+
+def action_tag():
+ """Get the tag for the currently executing action."""
+ return os.environ.get('JUJU_ACTION_TAG')
+
+
def status_set(workload_state, message):
"""Set the workload state with a message
diff --git a/hooks/charmhelpers/core/unitdata.py b/hooks/charmhelpers/core/unitdata.py
index 406a35c..338104e 100644
--- a/hooks/charmhelpers/core/unitdata.py
+++ b/hooks/charmhelpers/core/unitdata.py
@@ -152,6 +152,7 @@ associated to the hookname.
import collections
import contextlib
import datetime
+import itertools
import json
import os
import pprint
@@ -164,8 +165,7 @@ __author__ = 'Kapil Thangavelu '
class Storage(object):
"""Simple key value database for local unit state within charms.
- Modifications are automatically committed at hook exit. That's
- currently regardless of exit code.
+ Modifications are not persisted unless :meth:`flush` is called.
To support dicts, lists, integer, floats, and booleans values
are automatically json encoded/decoded.
@@ -173,8 +173,11 @@ class Storage(object):
def __init__(self, path=None):
self.db_path = path
if path is None:
- self.db_path = os.path.join(
- os.environ.get('CHARM_DIR', ''), '.unit-state.db')
+ if 'UNIT_STATE_DB' in os.environ:
+ self.db_path = os.environ['UNIT_STATE_DB']
+ else:
+ self.db_path = os.path.join(
+ os.environ.get('CHARM_DIR', ''), '.unit-state.db')
self.conn = sqlite3.connect('%s' % self.db_path)
self.cursor = self.conn.cursor()
self.revision = None
@@ -189,15 +192,8 @@ class Storage(object):
self.conn.close()
self._closed = True
- def _scoped_query(self, stmt, params=None):
- if params is None:
- params = []
- return stmt, params
-
def get(self, key, default=None, record=False):
- self.cursor.execute(
- *self._scoped_query(
- 'select data from kv where key=?', [key]))
+ self.cursor.execute('select data from kv where key=?', [key])
result = self.cursor.fetchone()
if not result:
return default
@@ -206,33 +202,81 @@ class Storage(object):
return json.loads(result[0])
def getrange(self, key_prefix, strip=False):
- stmt = "select key, data from kv where key like '%s%%'" % key_prefix
- self.cursor.execute(*self._scoped_query(stmt))
+ """
+ Get a range of keys starting with a common prefix as a mapping of
+ keys to values.
+
+ :param str key_prefix: Common prefix among all keys
+ :param bool strip: Optionally strip the common prefix from the key
+ names in the returned dict
+ :return dict: A (possibly empty) dict of key-value mappings
+ """
+ self.cursor.execute("select key, data from kv where key like ?",
+ ['%s%%' % key_prefix])
result = self.cursor.fetchall()
if not result:
- return None
+ return {}
if not strip:
key_prefix = ''
return dict([
(k[len(key_prefix):], json.loads(v)) for k, v in result])
def update(self, mapping, prefix=""):
+ """
+ Set the values of multiple keys at once.
+
+ :param dict mapping: Mapping of keys to values
+ :param str prefix: Optional prefix to apply to all keys in `mapping`
+ before setting
+ """
for k, v in mapping.items():
self.set("%s%s" % (prefix, k), v)
def unset(self, key):
+ """
+ Remove a key from the database entirely.
+ """
self.cursor.execute('delete from kv where key=?', [key])
if self.revision and self.cursor.rowcount:
self.cursor.execute(
'insert into kv_revisions values (?, ?, ?)',
[key, self.revision, json.dumps('DELETED')])
+ def unsetrange(self, keys=None, prefix=""):
+ """
+ Remove a range of keys starting with a common prefix, from the database
+ entirely.
+
+ :param list keys: List of keys to remove.
+ :param str prefix: Optional prefix to apply to all keys in ``keys``
+ before removing.
+ """
+ if keys is not None:
+ keys = ['%s%s' % (prefix, key) for key in keys]
+ self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
+ if self.revision and self.cursor.rowcount:
+ self.cursor.execute(
+ 'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
+ list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
+ else:
+ self.cursor.execute('delete from kv where key like ?',
+ ['%s%%' % prefix])
+ if self.revision and self.cursor.rowcount:
+ self.cursor.execute(
+ 'insert into kv_revisions values (?, ?, ?)',
+ ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
+
def set(self, key, value):
+ """
+ Set a value in the database.
+
+ :param str key: Key to set the value for
+ :param value: Any JSON-serializable value to be set
+ """
serialized = json.dumps(value)
- self.cursor.execute(
- 'select data from kv where key=?', [key])
+ self.cursor.execute('select data from kv where key=?', [key])
exists = self.cursor.fetchone()
# Skip mutations to the same value