Merge lp:~jimbaker/pyjuju/charm-format-2 into lp:pyjuju

Proposed by Jim Baker
Status: Merged
Merged at revision: 544
Proposed branch: lp:~jimbaker/pyjuju/charm-format-2
Merge into: lp:pyjuju
Diff against target: 1785 lines (+1100/-163)
21 files modified
juju/charm/config.py (+6/-1)
juju/charm/metadata.py (+16/-6)
juju/charm/tests/repository/series/mysql-format-v2/config.yaml (+17/-0)
juju/charm/tests/repository/series/mysql-format-v2/metadata.yaml (+6/-0)
juju/charm/tests/repository/series/mysql-format-v2/revision (+1/-0)
juju/charm/tests/repository/series/mysql/config.yaml (+17/-0)
juju/charm/tests/repository/series/mysql/metadata.yaml (+1/-0)
juju/charm/tests/test_config.py (+3/-3)
juju/charm/tests/test_metadata.py (+24/-0)
juju/control/config_get.py (+34/-35)
juju/control/config_set.py (+5/-3)
juju/control/tests/test_config_set.py (+134/-0)
juju/hooks/cli.py (+6/-29)
juju/hooks/invoker.py (+10/-0)
juju/hooks/protocol.py (+32/-18)
juju/hooks/tests/test_cli.py (+98/-57)
juju/hooks/tests/test_communications.py (+9/-2)
juju/hooks/tests/test_invoker.py (+175/-2)
juju/lib/format.py (+144/-0)
juju/lib/tests/test_format.py (+330/-0)
juju/state/tests/test_relation.py (+32/-7)
To merge this branch: bzr merge lp:~jimbaker/pyjuju/charm-format-2
Reviewer Review Type Date Requested Status
Juju Engineering Pending
Review via email: mp+108831@code.launchpad.net

Description of the change

Adds format: 2 support to juju, while maintaining backwards compatibility for old charms.

Overview
========

This merge proposal adds support for a new key to a charm's
``metadata.yaml`` file, ``format`` such that charms can choose to use
a more consistent formatting (YAML) when working with relation and
configuration settings, without a backwards breaking change. Another
important part of this change is consistency: YAML is used throughout,
which works well with the existing YAML storage of relation/config
settings in ZK itself.

Example metadata
================

Here's an example metadata file for format: 2 charms:

name: wordpress
summary: "WordPress is a full featured web blogging tool"
maintainer: Clint Byrum <email address hidden>
description: |
 WordPress is a full featured web blogging tool:
 - Instant publishing (no rebuilding)
 - Comment pingback support with spam protection
 - Non-crufty URLs
 - Themable
 - Plugin support
requires:
  db:
    interface: mysql
provides:
  website:
    interface: http
format: 2

format: 1
=========

When using format: 1 (this format is implied when the format key is
undefined in ``metadata.yaml``), Juju for this charm maintains
existing format support for specifying configuration and relation
settings and for outputting relation settings when using the smart
format for ``relation-get``.

When using ASCII strings to define scalar values, this is not an issue
for working with relation settings. For config settings, because
everything is a string, it's not possible to actually set boolean
config settings (regardless of the capitalization):

$ juju set wordpress good=True
2012-06-05 14:44:35,893 INFO Connecting to environment...
2012-06-05 14:44:38,890 INFO Connected to environment.
Invalid value for good: 'True'
2012-06-05 14:44:39,108 ERROR Invalid value for good: 'True'

When a map is output (``relation-get -``), smart format continues to
use the ``__str__`` formatting, in this case of the internally used
Python dict. In this, we also see the impact of how parsing all values
as strings (in relation-set/set), then using JSON for encoding
transport converts all strings to Unicode. This results in output like
the following:

{u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
 u"configured": u"True",
 u"foo": u"bar"}

Per the discussion on the mailing list, this exposes Python
implementation details and needs to be fixed, especially in light of
the Go port.

format: 2
=========

When the format is set to 2, relation (``relation-set``) and config
(``juju set``) settings are parsed as YAML. When using
``relation-get`` with smart format, output is in YAML, using the same
YAML formatting options as ``juju get`` for consistency/human
readibility.

Output for v2 format is as follows for scalars, each on a separate line:

true # Input was true (or True or TRUE, YAML is especially tolerant of its input)
false
A string

Maps are output like so. A bash script likely could even readily
written to parse this output, without the use of a helper:

configured: true
foo: bar
public-address: ec2-1-2-3-4.compute-1.amazonaws.com

It is possible for charms of different charm format to be deployed in
an environment. Besides unit testing, this scenario has been
extensively tested when deployed.

New environment variable: CHARM_FORMAT
======================================

For relation-set/relation-get, the new environment variable
``CHARM_FORMAT``, as set for the relation hook context, controls
parsing and output. This can also be used in debug hooks to see the
impact of choosing one format versus the other.

Implementation notes
====================

Overall code changes are small, with most changes seen in tests. In
addition, tests were added to describe the current observed format: 1
behavior.

Two parallel code paths were implemented in impacted code. If it's
desired to modify the behavior of format: 2, this can be readily done
on that code path.

yaml.safe_load and yaml.safe_dump are used to ensure that Python types
do not leak and to avoid introducing a security hole. The use of YAML
also allows for consistent support of Unicode strings (not using ASCII
codepoints) as well as binary data (as introduced via the !!binary
tag). Unlike the format: 1 implementation, the consistent use of YAML
for encoding/decoding ensures that there is not an unexpected encoding
issue that's encountered, such as seen in bug #901495.

https://codereview.appspot.com/6308044/

To post a comment you must log in.
Revision history for this message
Jim Baker (jimbaker) wrote :
Download full text (5.4 KiB)

Reviewers: mp+108831_code.launchpad.net,

Message:
Please take a look.

Description:
Adds format: 2 support to juju, while maintaining backwards
compatibility for old charms.

Overview
========

This merge proposal adds support for a new key to a charm's
``metadata.yaml`` file, ``format`` such that charms can choose to use
a more consistent formatting (YAML) when working with relation and
configuration settings, without a backwards breaking change. Another
important part of this change is consistency: YAML is used throughout,
which works well with the existing YAML storage of relation/config
settings in ZK itself.

Example metadata
================

Here's an example metadata file for format: 2 charms:

name: wordpress
summary: "WordPress is a full featured web blogging tool"
maintainer: Clint Byrum <email address hidden>
description: |
  WordPress is a full featured web blogging tool:
  - Instant publishing (no rebuilding)
  - Comment pingback support with spam protection
  - Non-crufty URLs
  - Themable
  - Plugin support
requires:
   db:
     interface: mysql
provides:
   website:
     interface: http
format: 2

format: 1
=========

When using format: 1 (this format is implied when the format key is
undefined in ``metadata.yaml``), Juju for this charm maintains
existing format support for specifying configuration and relation
settings and for outputting relation settings when using the smart
format for ``relation-get``.

When using ASCII strings to define scalar values, this is not an issue
for working with relation settings. For config settings, because
everything is a string, it's not possible to actually set boolean
config settings (regardless of the capitalization):

$ juju set wordpress good=True
2012-06-05 14:44:35,893 INFO Connecting to environment...
2012-06-05 14:44:38,890 INFO Connected to environment.
Invalid value for good: 'True'
2012-06-05 14:44:39,108 ERROR Invalid value for good: 'True'

When a map is output (``relation-get -``), smart format continues to
use the ``__str__`` formatting, in this case of the internally used
Python dict. In this, we also see the impact of how parsing all values
as strings (in relation-set/set), then using JSON for encoding
transport converts all strings to Unicode. This results in output like
the following:

{u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
  u"configured": u"True",
  u"foo": u"bar"}

Per the discussion on the mailing list, this exposes Python
implementation details and needs to be fixed, especially in light of
the Go port.

format: 2
=========

When the format is set to 2, relation (``relation-set``) and config
(``juju set``) settings are parsed as YAML. When using
``relation-get`` with smart format, output is in YAML, using the same
YAML formatting options as ``juju get`` for consistency/human
readibility.

Output for v2 format is as follows for scalars, each on a separate line:

true # Input was true (or True or TRUE, YAML is especially tolerant
of its input)
false
A string

Maps are output like so. A bash script likely could even readily
written to parse this output, without the use of a helper:

configured: true
foo: bar
public-address: ec2-1-2-3-4.compute-1....

Read more...

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

I haven't reviewed the code, but the description of the change looks
good.

One detail that deserves some attention is the use of the CHARM_FORMAT
environment variable. If we have it, it should be named
JUJU_CHARM_FORMAT, similar to other variables we have. That said, do we
actually need this variable? This sounds like an implementation detail
that is not worth exposing. relation-get and relation-set both
communicate with the unit, and can readily discover the format based on
that.

https://codereview.appspot.com/6308044/

Revision history for this message
Gustavo Niemeyer (niemeyer) wrote :

On further thinking, if the environment variable makes the change
simpler, we may as well just do it. Can we please make this purely an
implementation detail, though: call it _JUJU_CHARM_FORMAT, no docs. How
does that sound?

https://codereview.appspot.com/6308044/

Revision history for this message
Jim Baker (jimbaker) wrote :

On 2012/06/06 02:18:58, niemeyer wrote:
> On further thinking, if the environment variable makes the change
simpler, we
> may as well just do it. Can we please make this purely an
implementation detail,
> though: call it _JUJU_CHARM_FORMAT, no docs. How does that sound?

I like this suggestion: _JUJU_CHARM_FORMAT properly suggests this
environment variable is an implementation detail. The use of the env var
does simplify the implementation.

https://codereview.appspot.com/6308044/

Revision history for this message
William Reade (fwereade) wrote :
Download full text (5.7 KiB)

OK, this is a bit of a monster review... sorry :(. I have some
suggestions that could possibly simplify things quite a lot; please let
me know what you think.

https://codereview.appspot.com/6308044/diff/1/juju/charm/metadata.py
File juju/charm/metadata.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/charm/metadata.py#newcode242
juju/charm/metadata.py:242: if self.format not in (1, 2):
(PYTHON_FORMAT, YAML_FORMAT)?

https://codereview.appspot.com/6308044/diff/1/juju/control/config_set.py
File juju/control/config_set.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/control/config_set.py#newcode98
juju/control/config_set.py:98: os.environ["CHARM_FORMAT"] =
str(charm_format)
This feels like a strange way to go about modifying the behaviour of
parse_keyvalue_pairs below...

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py
File juju/hooks/cli.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode17
juju/hooks/cli.py:17: PYTHON_FORMAT, YAML_FORMAT = 1, 2
I feel like these should probably be somewhere else, if only because I
see late imports here and there. Suggestion: they, and
parse_keyvalue_pairs, should go somewhere else together; possibly
juju.charm?

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode243
juju/hooks/cli.py:243: print >>stream, serialized.rstrip("\n")
Appears to be duplicated in juju/charm/config.py; utility function?

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode247
juju/hooks/cli.py:247: """Return the format defined by $CHARM_FORMAT"""
Please make this function module-private; IMO this is the only place we
should be getting charm format from the environment (as opposed to...
from a charm ;)).

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode248
juju/hooks/cli.py:248: env_charm_format = os.environ.get("CHARM_FORMAT")
_JUJU_CHARM_FORMAT

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode275
juju/hooks/cli.py:275: charm_format = get_charm_format()
charm_format should be a parameter; it's easily accessible to both
clients.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/invoker.py
File juju/hooks/invoker.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/hooks/invoker.py#newcode151
juju/hooks/invoker.py:151: self._charm_format = None
Add an accessor so the UnitAgentServer can get at this value.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/invoker.py#newcode213
juju/hooks/invoker.py:213: CHARM_FORMAT=str(self._charm_format),
+1 on _JUJU_CHARM_FORMAT as suggested elsewhere. (note: it won't be
necessary in the go port.)

https://codereview.appspot.com/6308044/diff/1/juju/hooks/protocol.py
File juju/hooks/protocol.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/hooks/protocol.py#newcode101
juju/hooks/protocol.py:101: ("format", amp.Integer())]
This shouldn't need to be sent up here. The HookInvoker knows what the
value is, and the UnitAgentServer can access it via
self.factory.get_invoker(client_id).

https://codereview.appspot.com/6308044/diff/1/juju/hooks/protocol.py#newcode109
juju/hooks/protocol.py:109: ("format", a...

Read more...

Revision history for this message
William Reade (fwereade) wrote :

Another note: relation-ids and relation-list don't generate yaml; this
is odd, given that we favour yaml everywhere else; and while they should
not *default* to outputting yaml (ie we leave their format_smart methods
alone), we really *should* at least make the option available (by
implementing format_yaml on the base class).

https://codereview.appspot.com/6308044/

Revision history for this message
William Reade (fwereade) wrote :

Comments re: internal serialization format withdrawn; they're necessary
to preserve charm format 1 bugginess. Would you comment this explicitly
in somewhere protocol.py?

https://codereview.appspot.com/6308044/

Revision history for this message
Jim Baker (jimbaker) wrote :
Download full text (5.6 KiB)

Please take a look.

https://codereview.appspot.com/6308044/diff/1/juju/charm/metadata.py
File juju/charm/metadata.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/charm/metadata.py#newcode242
juju/charm/metadata.py:242: if self.format not in (1, 2):
On 2012/06/06 13:34:40, fwereade wrote:
> (PYTHON_FORMAT, YAML_FORMAT)?

Done.

https://codereview.appspot.com/6308044/diff/1/juju/control/config_set.py
File juju/control/config_set.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/control/config_set.py#newcode98
juju/control/config_set.py:98: os.environ["CHARM_FORMAT"] =
str(charm_format)
Agreed, just calls the refactored version of parse_keyvalue_pairs that
takes a charm format. Not certain what possessed me to write this code
this way, must have really want to avoid refactoring at that point :)

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py
File juju/hooks/cli.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode17
juju/hooks/cli.py:17: PYTHON_FORMAT, YAML_FORMAT = 1, 2
Moved to juju.lib.format. Putting this in juju.charm would seem to have
too much cross module dependency on what are otherwise independent
modules.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode243
juju/hooks/cli.py:243: print >>stream, serialized.rstrip("\n")
Created juju.lib.format and moved this functionality there so it can be
reused as suggested.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode247
juju/hooks/cli.py:247: """Return the format defined by $CHARM_FORMAT"""
That won't work, it's also needed by juju.hooks.protocol

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode248
juju/hooks/cli.py:248: env_charm_format = os.environ.get("CHARM_FORMAT")
On 2012/06/06 13:34:40, fwereade wrote:
> _JUJU_CHARM_FORMAT

Done.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/cli.py#newcode275
juju/hooks/cli.py:275: charm_format = get_charm_format()
On 2012/06/06 13:34:40, fwereade wrote:
> charm_format should be a parameter; it's easily accessible to both
clients.

Done.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/invoker.py
File juju/hooks/invoker.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/hooks/invoker.py#newcode151
juju/hooks/invoker.py:151: self._charm_format = None
On 2012/06/06 13:34:40, fwereade wrote:
> Add an accessor so the UnitAgentServer can get at this value.

Done.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/invoker.py#newcode213
juju/hooks/invoker.py:213: CHARM_FORMAT=str(self._charm_format),
On 2012/06/06 13:34:40, fwereade wrote:
> +1 on _JUJU_CHARM_FORMAT as suggested elsewhere. (note: it won't be
necessary in
> the go port.)

Done.

https://codereview.appspot.com/6308044/diff/1/juju/hooks/protocol.py
File juju/hooks/protocol.py (right):

https://codereview.appspot.com/6308044/diff/1/juju/hooks/protocol.py#newcode101
juju/hooks/protocol.py:101: ("format", amp.Integer())]
On 2012/06/06 13:34:40, fwereade wrote:
> This shouldn't need to be sent up here. The HookInvoker knows what the
value is,
> and the UnitAgentServer can access it via
self.factory.get_invoker(cl...

Read more...

lp:~jimbaker/pyjuju/charm-format-2 updated
559. By Jim Baker

Refactor def of PYTHON_FORMAT/YAML_FORMAT constants

560. By Jim Baker

Refactored parse_keyvalue_pairs

561. By Jim Baker

Use the invoker on the unit agent side of the protocol to retrieve charm_format

562. By Jim Baker

Refactored code out of juju.hooks.cli into juju.hooks.utils to avoid circular imports

563. By Jim Baker

Refactor to put standard formatting logic in juju.lib.format

564. By Jim Baker

Fix failing tests

565. By Jim Baker

Refactored format support for parsing/output to be shared across juju

566. By Jim Baker

Do not use str.format

567. By Jim Baker

PEP8/PyFlakes

568. By Jim Baker

Removed debug log stmt

Revision history for this message
Jim Baker (jimbaker) wrote :

Comments addressed in the latest version of the proposal.

https://codereview.appspot.com/6308044/

Revision history for this message
Jim Baker (jimbaker) wrote :
Revision history for this message
Kapil Thangavelu (hazmat) wrote :

http://codereview.appspot.com/6308044/diff/10001/juju/charm/metadata.py
File juju/charm/metadata.py (right):

http://codereview.appspot.com/6308044/diff/10001/juju/charm/metadata.py#newcode239
juju/charm/metadata.py:239: if self.format not in (PYTHON_FORMAT,
YAML_FORMAT):
This seems to be conflating two different things, charm api versions and
there corresponding formats. ie. we could add version 3, with the
existing yaml format. there should be a mapping of format to version.
the charm isn't making a request for format, its making a version api
assertion.

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/protocol.py
File juju/hooks/protocol.py (right):

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/protocol.py#newcode220
juju/hooks/protocol.py:220: if invoker.charm_format == PYTHON_FORMAT:
instead of conditional logic everywhere in this file, this process would
be simpler/cleaner dispatched to a formatter object from lib/format as
an instance property.

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/utils.py
File juju/hooks/utils.py (right):

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/utils.py#newcode10
juju/hooks/utils.py:10: env_charm_format =
os.environ.get("_JUJU_CHARM_FORMAT")
this feels a bit icky, its an immutable property of a charm being passed
as a mutable parameter. the server side could return the format in its
response.

http://codereview.appspot.com/6308044/

Revision history for this message
Jim Baker (jimbaker) wrote :
lp:~jimbaker/pyjuju/charm-format-2 updated
569. By Jim Baker

Initial strategy refactoring

570. By Jim Baker

Do not expose YAMLFormat outside of juju.lib.format

571. By Jim Baker

Removed YAMLFormat, PythonFormat, etc in favor of using Format2, Forma1

572. By Jim Baker

More tests in juju.lib.format

573. By Jim Baker

More tests in juju.lib.format

574. By Jim Baker

More tests

575. By Jim Baker

Yet more tests/docstrings

576. By Jim Baker

PyFlakes

577. By Jim Baker

Merged trunk

Revision history for this message
Jim Baker (jimbaker) wrote :

Please take a look at the latest proposed version of this branch, this
should solve the issues that were raised.

http://codereview.appspot.com/6308044/diff/10001/juju/charm/metadata.py
File juju/charm/metadata.py (right):

http://codereview.appspot.com/6308044/diff/10001/juju/charm/metadata.py#newcode239
juju/charm/metadata.py:239: if self.format not in (PYTHON_FORMAT,
YAML_FORMAT):
On 2012/06/12 21:18:08, hazmat wrote:
> This seems to be conflating two different things, charm api versions
and there
> corresponding formats. ie. we could add version 3, with the existing
yaml
> format. there should be a mapping of format to version. the charm
isn't making a
> request for format, its making a version api assertion.

I changed this so there's a definite distinction, through the use of
Format1 and Format2 classes (no more PYTHON_FORMAT, YAML_FORMAT). Assume
at some point we have a format: 3 and it's supported by the Python code,
then we could readily create a Format3 class that either derives from
Format2, or from BaseFormat, or what makes sense. So this separates the
charm version information (and any assertions) from the implementation.

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/protocol.py
File juju/hooks/protocol.py (right):

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/protocol.py#newcode220
juju/hooks/protocol.py:220: if invoker.charm_format == PYTHON_FORMAT:
The code has been changed such that one can get an appropriate formatter
(Format1, Format2 for now) and do the appropriate action, without the
conditional logic being replicated throughout as you've observed.

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/utils.py
File juju/hooks/utils.py (right):

http://codereview.appspot.com/6308044/diff/10001/juju/hooks/utils.py#newcode10
juju/hooks/utils.py:10: env_charm_format =
os.environ.get("_JUJU_CHARM_FORMAT")
I have changed this usage where feasible to what you describe, however,
in cases like relation_set, it's necessary to do the parsing on the
client side before calling the server side of the implementation. In
this case, it's still necessary to define _JUJU_CHARM_FORMAT. However,
this lookup is now more isolated, because it's been moved to
juju.lib.format.

http://codereview.appspot.com/6308044/

Revision history for this message
William Reade (fwereade) wrote :

Damn, I'm sorry, I had an unpublished LGTM sitting here for days :(((.

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/tests/test_invoker.py
File juju/hooks/tests/test_invoker.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/tests/test_invoker.py#newcode511
juju/hooks/tests/test_invoker.py:511: @defer.inlineCallbacks
Heh, nice catches :).

https://codereview.appspot.com/6308044/diff/11004/juju/lib/format.py
File juju/lib/format.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/lib/format.py#newcode144
juju/lib/format.py:144: os.environ.get("_JUJU_CHARM_FORMAT", "1")))
Thanks, all this is much clearer.

https://codereview.appspot.com/6308044/

Revision history for this message
Kapil Thangavelu (hazmat) wrote :

i'd still like to get rid of the env var, but i can live with it. just
changing the format2 to yamlformat, format1 to pythonformat and it lgtm.

https://codereview.appspot.com/6308044/diff/11004/juju/charm/config.py
File juju/charm/config.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/charm/config.py#newcode8
juju/charm/config.py:8: from juju.lib.schema import (SchemaError,
KeyDict, Dict, String,
please call this yamlformat, format2 is not what the output format is.

https://codereview.appspot.com/6308044/diff/11004/juju/charm/metadata.py
File juju/charm/metadata.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/charm/metadata.py#newcode143
juju/charm/metadata.py:143: """Optional charm format, defaults to 1"""
minor, a pointer to a reference on what these entail would be useful
here.

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/protocol.py
File juju/hooks/protocol.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/protocol.py#newcode405
juju/hooks/protocol.py:405: formatter = get_charm_formatter_from_env()
you don't need this here, this is an internal transport detail, you can
just pick a format, and that removes the need for env formatter
selection. the server knowing the charm format can specify it in its
response.

https://codereview.appspot.com/6308044/

Revision history for this message
Jim Baker (jimbaker) wrote :

On 2012/06/19 20:01:09, hazmat wrote:
> i'd still like to get rid of the env var, but i can live with it. just
changing
> the format2 to yamlformat, format1 to pythonformat and it lgtm.

Please note that format: 1 implies both Python str encoding and the use
of JSON dump/load which brings in Unicode promotion of strings (part of
the underlying bugginess). So that's why I chose to use Format1 here.
format:2 cleans this up by using YAML throughout, matching the
serialization into ZK for relation settings.

However, it's certainly easy enough to change to PythonFormat and
YAMLFormat, just wanted to explain the rationale here.

  https://codereview.appspot.com/6308044/diff/11004/juju/charm/config.py
> File juju/charm/config.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/charm/config.py#newcode8
> juju/charm/config.py:8: from juju.lib.schema import (SchemaError,
KeyDict, Dict,
> String,
> please call this yamlformat, format2 is not what the output format is.

https://codereview.appspot.com/6308044/diff/11004/juju/charm/metadata.py
> File juju/charm/metadata.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/charm/metadata.py#newcode143
> juju/charm/metadata.py:143: """Optional charm format, defaults to 1"""
> minor, a pointer to a reference on what these entail would be useful
here.

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/protocol.py
> File juju/hooks/protocol.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/protocol.py#newcode405
> juju/hooks/protocol.py:405: formatter = get_charm_formatter_from_env()
> you don't need this here, this is an internal transport detail, you
can just
> pick a format, and that removes the need for env formatter selection.
the server
> knowing the charm format can specify it in its response.

https://codereview.appspot.com/6308044/

Revision history for this message
Jim Baker (jimbaker) wrote :

Responded to Kapil's comments.

https://codereview.appspot.com/6308044/diff/11004/juju/charm/config.py
File juju/charm/config.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/charm/config.py#newcode8
juju/charm/config.py:8: from juju.lib.schema import (SchemaError,
KeyDict, Dict, String,
On 2012/06/19 20:01:09, hazmat wrote:
> please call this yamlformat, format2 is not what the output format is.

Ack.

https://codereview.appspot.com/6308044/diff/11004/juju/charm/metadata.py
File juju/charm/metadata.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/charm/metadata.py#newcode143
juju/charm/metadata.py:143: """Optional charm format, defaults to 1"""
On 2012/06/19 20:01:09, hazmat wrote:
> minor, a pointer to a reference on what these entail would be useful
here.

Ack.

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/protocol.py
File juju/hooks/protocol.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/protocol.py#newcode405
juju/hooks/protocol.py:405: formatter = get_charm_formatter_from_env()
On 2012/06/19 20:01:09, hazmat wrote:
> you don't need this here, this is an internal transport detail, you
can just
> pick a format, and that removes the need for env formatter selection.
the server
> knowing the charm format can specify it in its response.

Actually I tried this early on, and found that the proposed strategy
doesn't work. The problem is that format: 1 is sensitive to the fact
that it uses a JSON encoding/decoding step, and this is tested, among
other places, by juju.hooks.tests.test_invoker.TestCharmFormatV1
(changed to TestPythonFormat). William raised the same issue in his
report, and we discussed on IRC earlier. See
https://codereview.appspot.com/6308044/diff/1/juju/hooks/protocol.py#newcode219
and https://codereview.appspot.com/6308044/#msg7. In particular, this
would make the unicode promotion from str go away. That's a nice thing,
but bugginess is what we are trying to preserve.

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/tests/test_invoker.py
File juju/hooks/tests/test_invoker.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/hooks/tests/test_invoker.py#newcode511
juju/hooks/tests/test_invoker.py:511: @defer.inlineCallbacks
On 2012/06/18 22:46:41, fwereade wrote:
> Heh, nice catches :).
Thanks!

https://codereview.appspot.com/6308044/diff/11004/juju/lib/format.py
File juju/lib/format.py (right):

https://codereview.appspot.com/6308044/diff/11004/juju/lib/format.py#newcode144
juju/lib/format.py:144: os.environ.get("_JUJU_CHARM_FORMAT", "1")))
On 2012/06/18 22:46:41, fwereade wrote:
> Thanks, all this is much clearer.

Cool!

https://codereview.appspot.com/6308044/

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'juju/charm/config.py'
2--- juju/charm/config.py 2012-03-07 21:19:46 +0000
3+++ juju/charm/config.py 2012-06-15 02:00:25 +0000
4@@ -4,6 +4,7 @@
5
6 import yaml
7
8+from juju.lib.format import Format2
9 from juju.lib.schema import (SchemaError, KeyDict, Dict, String,
10 Constant, OneOf, Int, Float)
11 from juju.charm.errors import (
12@@ -138,8 +139,12 @@
13 value, valid = validator(value, self._data[name])
14
15 if not valid:
16+ # Return value such that it roundtrips; this allows us to
17+ # report back the boolean false instead of the Python
18+ # output format, False
19 raise ServiceConfigValueError(
20- "Invalid value for %s: %r" % (name, value))
21+ "Invalid value for %s: %s" % (
22+ name, Format2().format(value)))
23 return value
24
25 def get_defaults(self):
26
27=== modified file 'juju/charm/metadata.py'
28--- juju/charm/metadata.py 2012-02-28 01:47:53 +0000
29+++ juju/charm/metadata.py 2012-06-15 02:00:25 +0000
30@@ -5,20 +5,17 @@
31
32 from juju.charm.errors import MetaDataError
33 from juju.errors import FileNotFound
34+from juju.lib.format import is_valid_charm_format
35 from juju.lib.schema import (
36 SchemaError, Bool, Constant, Dict, Int,
37 KeyDict, OneOf, UnicodeOrString)
38
39
40 log = logging.getLogger("juju.charm")
41-
42-
43 UTF8_SCHEMA = UnicodeOrString("utf-8")
44-
45 SCOPE_GLOBAL = "global"
46 SCOPE_CONTAINER = "container"
47
48-
49 INTERFACE_SCHEMA = KeyDict({
50 "interface": UTF8_SCHEMA,
51 "limit": OneOf(Constant(None), Int()),
52@@ -92,11 +89,14 @@
53 "revision": Int(),
54 "summary": UTF8_SCHEMA,
55 "description": UTF8_SCHEMA,
56+ "format": Int(),
57 "peers": Dict(UTF8_SCHEMA, InterfaceExpander(limit=1)),
58 "provides": Dict(UTF8_SCHEMA, InterfaceExpander(limit=None)),
59 "requires": Dict(UTF8_SCHEMA, InterfaceExpander(limit=1)),
60 "subordinate": Bool(),
61- }, optional=set(["provides", "requires", "peers", "revision", "subordinate"]))
62+ }, optional=set(
63+ ["format", "provides", "requires", "peers", "revision",
64+ "subordinate"]))
65
66
67 class MetaData(object):
68@@ -139,6 +139,11 @@
69 return self._data.get("description")
70
71 @property
72+ def format(self):
73+ """Optional charm format, defaults to 1"""
74+ return self._data.get("format", 1)
75+
76+ @property
77 def provides(self):
78 """The charm provides relations."""
79 return self._data.get("provides")
80@@ -211,7 +216,8 @@
81 for rel in self.provides:
82 if rel.startswith("juju-"):
83 raise MetaDataError(
84- "Charm %s attempting to provide relation in implicit relation namespace: %s" %
85+ "Charm %s attempting to provide relation in "
86+ "implicit relation namespace: %s" %
87 (self.name, rel))
88 interface = self.provides[rel]["interface"]
89 if interface.startswith("juju-"):
90@@ -230,6 +236,10 @@
91 "%s labeled subordinate but lacking scope:container `requires` relation" %
92 path)
93
94+ if not is_valid_charm_format(self.format):
95+ raise MetaDataError("Charm %s uses an unknown format: %s" % (
96+ self.name, self.format))
97+
98 def parse_serialization_data(self, serialization_data, path=None):
99 """Parse the unprocessed serialization data and load in this instance.
100
101
102=== added directory 'juju/charm/tests/repository/series/mysql-format-v2'
103=== added file 'juju/charm/tests/repository/series/mysql-format-v2/config.yaml'
104--- juju/charm/tests/repository/series/mysql-format-v2/config.yaml 1970-01-01 00:00:00 +0000
105+++ juju/charm/tests/repository/series/mysql-format-v2/config.yaml 2012-06-15 02:00:25 +0000
106@@ -0,0 +1,17 @@
107+options:
108+ query-cache-size:
109+ default: -1
110+ type: int
111+ description: Override the computed version from dataset-size. Still works if query-cache-type is "OFF" since sessions can override the cache type setting on their own.
112+ awesome:
113+ default: false
114+ type: boolean
115+ description: Set true to make this database engine truly awesome
116+ tuning-level:
117+ default: safest
118+ type: string
119+ description: Valid values are 'safest', 'fast', and 'unsafe'. If set to safest, all settings are tuned to have maximum safety at the cost of performance. Fast will turn off most controls, but may lose data on crashes. unsafe will turn off all protections.
120+ monkey-madness:
121+ default: 0.5
122+ type: float
123+ description: The amount of randomness to be desired in any data that is returned, from 0 (sane) to 1 (monkeys running the asylum).
124
125=== added file 'juju/charm/tests/repository/series/mysql-format-v2/metadata.yaml'
126--- juju/charm/tests/repository/series/mysql-format-v2/metadata.yaml 1970-01-01 00:00:00 +0000
127+++ juju/charm/tests/repository/series/mysql-format-v2/metadata.yaml 2012-06-15 02:00:25 +0000
128@@ -0,0 +1,6 @@
129+name: mysql-format-v2
130+summary: "Database engine"
131+description: "A pretty popular database"
132+provides:
133+ server: mysql
134+format: 2
135
136=== added file 'juju/charm/tests/repository/series/mysql-format-v2/revision'
137--- juju/charm/tests/repository/series/mysql-format-v2/revision 1970-01-01 00:00:00 +0000
138+++ juju/charm/tests/repository/series/mysql-format-v2/revision 2012-06-15 02:00:25 +0000
139@@ -0,0 +1,1 @@
140+1
141\ No newline at end of file
142
143=== added file 'juju/charm/tests/repository/series/mysql/config.yaml'
144--- juju/charm/tests/repository/series/mysql/config.yaml 1970-01-01 00:00:00 +0000
145+++ juju/charm/tests/repository/series/mysql/config.yaml 2012-06-15 02:00:25 +0000
146@@ -0,0 +1,17 @@
147+options:
148+ query-cache-size:
149+ default: -1
150+ type: int
151+ description: Override the computed version from dataset-size. Still works if query-cache-type is "OFF" since sessions can override the cache type setting on their own.
152+ awesome:
153+ default: false
154+ type: boolean
155+ description: Set true to make this database engine truly awesome
156+ tuning-level:
157+ default: safest
158+ type: string
159+ description: Valid values are 'safest', 'fast', and 'unsafe'. If set to safest, all settings are tuned to have maximum safety at the cost of performance. Fast will turn off most controls, but may lose data on crashes. unsafe will turn off all protections.
160+ monkey-madness:
161+ default: 0.5
162+ type: float
163+ description: The amount of randomness to be desired in any data that is returned, from 0 (sane) to 1 (monkeys running the asylum).
164
165=== modified file 'juju/charm/tests/repository/series/mysql/metadata.yaml'
166--- juju/charm/tests/repository/series/mysql/metadata.yaml 2011-10-04 19:13:12 +0000
167+++ juju/charm/tests/repository/series/mysql/metadata.yaml 2012-06-15 02:00:25 +0000
168@@ -3,3 +3,4 @@
169 description: "A pretty popular database"
170 provides:
171 server: mysql
172+format: 1
173
174=== modified file 'juju/charm/tests/test_config.py'
175--- juju/charm/tests/test_config.py 2012-03-07 21:19:46 +0000
176+++ juju/charm/tests/test_config.py 2012-06-15 02:00:25 +0000
177@@ -89,7 +89,7 @@
178 "type": "string",
179 "default": True}}}))
180 self.assertEqual(
181- str(e), "Invalid value for foobar: True")
182+ str(e), "Invalid value for foobar: true")
183
184 def test_as_dict(self):
185 # load valid data
186@@ -160,7 +160,7 @@
187
188 error = self.assertRaises(ServiceConfigValueError,
189 self.config.validate, {"title": True})
190- self.assertEquals(str(error), "Invalid value for title: True")
191+ self.assertEquals(str(error), "Invalid value for title: true")
192
193 data = self.config.validate({"title": u"Good"})
194 self.assertEqual(data, {"title": u"Good"})
195@@ -184,7 +184,7 @@
196
197 error = self.assertRaises(ServiceConfigValueError,
198 self.config.validate, {"skill-level": "NaN"})
199- self.assertEquals(str(error), "Invalid value for skill-level: 'NaN'")
200+ self.assertEquals(str(error), "Invalid value for skill-level: NaN")
201
202 data = self.config.validate({"skill-level": "9001"})
203 # its over 9000!
204
205=== modified file 'juju/charm/tests/test_metadata.py'
206--- juju/charm/tests/test_metadata.py 2012-02-28 01:47:53 +0000
207+++ juju/charm/tests/test_metadata.py 2012-06-15 02:00:25 +0000
208@@ -60,6 +60,7 @@
209 self.assertEquals(self.metadata.summary, None)
210 self.assertEquals(self.metadata.description, None)
211 self.assertEquals(self.metadata.is_subordinate, False)
212+ self.assertEquals(self.metadata.format, 1)
213
214 def test_parse_and_check_basic_info(self):
215 """
216@@ -238,6 +239,29 @@
217 "Charm dummy attempting to provide interface in implicit namespace: juju-magic (relation: foo-rel)",
218 str(error))
219
220+ def test_format(self):
221+ # Defaults to 1
222+ self.metadata.parse(self.sample)
223+ self.assertEquals(self.metadata.format, 1)
224+
225+ # Explicitly set to 1
226+ with self.change_sample() as data:
227+ data["format"] = 1
228+ self.metadata.parse(self.sample)
229+ self.assertEquals(self.metadata.format, 1)
230+
231+ # Explicitly set to 2
232+ with self.change_sample() as data:
233+ data["format"] = 2
234+ self.metadata.parse(self.sample)
235+ self.assertEquals(self.metadata.format, 2)
236+
237+ # Explicitly set to 3; however this is an unknown format for Juju
238+ with self.change_sample() as data:
239+ data["format"] = 3
240+ error = self.assertRaises(MetaDataError, self.metadata.parse, self.sample)
241+ self.assertIn("Charm dummy uses an unknown format: 3", str(error))
242+
243
244 class ParseTest(TestCase):
245 """Test the parsing of some well-known sample files"""
246
247=== modified file 'juju/control/config_get.py'
248--- juju/control/config_get.py 2012-01-30 22:20:37 +0000
249+++ juju/control/config_get.py 2012-06-15 02:00:25 +0000
250@@ -1,9 +1,9 @@
251 import argparse
252-import yaml
253
254 from twisted.internet.defer import inlineCallbacks
255
256 from juju.control.utils import get_environment
257+from juju.lib.format import Format2
258 from juju.state.service import ServiceStateManager
259
260
261@@ -59,38 +59,37 @@
262 """
263 provider = environment.get_machine_provider()
264 client = yield provider.connect()
265-
266- # Get the service
267- service_manager = ServiceStateManager(client)
268- service = yield service_manager.get_service_state(service_name)
269-
270- # Retrieve schema
271- charm = yield service.get_charm_state()
272- schema = yield charm.get_config()
273- schema_dict = schema.as_dict()
274- display_dict = {"service": service.service_name,
275- "charm": (yield service.get_charm_id()),
276- "settings": schema_dict}
277-
278- # Get current settings
279- settings = yield service.get_config()
280- settings = dict(settings.items())
281-
282- # Merge current settings into schema/display dict
283- for k, v in schema_dict.items():
284- # Display defaults for unset values.
285- if k in settings:
286- v['value'] = settings[k]
287- else:
288- v['value'] = "-Not set-"
289-
290- if 'default' in v:
291- if v['default'] == settings[k]:
292- v['default'] = True
293+ try:
294+ # Get the service
295+ service_manager = ServiceStateManager(client)
296+ service = yield service_manager.get_service_state(service_name)
297+
298+ # Retrieve schema
299+ charm = yield service.get_charm_state()
300+ schema = yield charm.get_config()
301+ schema_dict = schema.as_dict()
302+ display_dict = {"service": service.service_name,
303+ "charm": (yield service.get_charm_id()),
304+ "settings": schema_dict}
305+
306+ # Get current settings
307+ settings = yield service.get_config()
308+ settings = dict(settings.items())
309+
310+ # Merge current settings into schema/display dict
311+ for k, v in schema_dict.items():
312+ # Display defaults for unset values.
313+ if k in settings:
314+ v['value'] = settings[k]
315 else:
316- del v['default']
317-
318- print yaml.safe_dump(
319- display_dict,
320- indent=4, default_flow_style=False, width=80, allow_unicode=True)
321- client.close()
322+ v['value'] = "-Not set-"
323+
324+ if 'default' in v:
325+ if v['default'] == settings[k]:
326+ v['default'] = True
327+ else:
328+ del v['default']
329+
330+ print Format2().format(display_dict)
331+ finally:
332+ yield client.close()
333
334=== modified file 'juju/control/config_set.py'
335--- juju/control/config_set.py 2012-04-04 13:00:13 +0000
336+++ juju/control/config_set.py 2012-06-15 02:00:25 +0000
337@@ -6,7 +6,7 @@
338
339 from juju.charm.errors import ServiceConfigValueError
340 from juju.control.utils import get_environment
341-from juju.hooks.cli import parse_keyvalue_pairs
342+from juju.lib.format import get_charm_formatter
343 from juju.state.service import ServiceStateManager
344
345
346@@ -62,7 +62,7 @@
347
348 yaml_data = options.config.read()
349 try:
350- data = yaml.load(yaml_data)
351+ data = yaml.safe_load(yaml_data)
352 except yaml.YAMLError:
353 raise ServiceConfigValueError(
354 "Config file %r not valid YAML" % options.config.name)
355@@ -93,6 +93,8 @@
356 service_manager = ServiceStateManager(client)
357 service = yield service_manager.get_service_state(service_name)
358 charm = yield service.get_charm_state()
359+ charm_format = (yield charm.get_metadata()).format
360+ formatter = get_charm_formatter(charm_format)
361
362 # Use the charm's ConfigOptions instance to validate the
363 # arguments to config_set. Invalid options passed to this method
364@@ -100,7 +102,7 @@
365 if isinstance(service_options, dict):
366 options = service_options
367 else:
368- options = parse_keyvalue_pairs(service_options)
369+ options = formatter.parse_keyvalue_pairs(service_options)
370
371 config = yield charm.get_config()
372 # ignore the output of validate, we run it so it might throw an exception
373
374=== modified file 'juju/control/tests/test_config_set.py'
375--- juju/control/tests/test_config_set.py 2012-03-27 22:46:44 +0000
376+++ juju/control/tests/test_config_set.py 2012-06-15 02:00:25 +0000
377@@ -182,3 +182,137 @@
378 yield state.read()
379 self.assertEqual(state, {"foo": "new foo",
380 "bar": "new bar"})
381+
382+ @inlineCallbacks
383+ def test_boolean_option_invalid_format_v1(self):
384+ """Verify not possible to set a boolean option with format v1"""
385+ self.service_state = yield self.add_service_from_charm("mysql")
386+ finished = self.setup_cli_reactor()
387+ self.setup_exit(0)
388+ self.mocker.replay()
389+ main(["set", "mysql","awesome=true"])
390+ yield finished
391+ self.assertEqual(
392+ self.stderr.getvalue(),
393+ "Invalid value for awesome: 'true'\n")
394+ state = yield self.service_state.get_config()
395+ self.assertEqual(
396+ state,
397+ {"awesome": False, "monkey-madness": 0.5,
398+ "query-cache-size": -1, "tuning-level": "safest"})
399+
400+ @inlineCallbacks
401+ def test_int_option_coerced_format_v1(self):
402+ """Verify int feasible, but coerced internally to str in format v1"""
403+ self.service_state = yield self.add_service_from_charm("mysql")
404+ finished = self.setup_cli_reactor()
405+ self.setup_exit(0)
406+ self.mocker.replay()
407+ main(["set", "mysql","query-cache-size=10"])
408+ yield finished
409+ self.assertEqual(self.stderr.getvalue(), "")
410+ state = yield self.service_state.get_config()
411+ self.assertEqual(
412+ state,
413+ {"awesome": False, "monkey-madness": 0.5,
414+ "query-cache-size": "10", "tuning-level": "safest"})
415+
416+ @inlineCallbacks
417+ def test_float_option_invalid_format_v1(self):
418+ """Verify not possible to set a float option with format v1"""
419+ self.service_state = yield self.add_service_from_charm("mysql")
420+ finished = self.setup_cli_reactor()
421+ self.setup_exit(0)
422+ self.mocker.replay()
423+ main(["set", "mysql","monkey-madness=0.99999999"])
424+ yield finished
425+ self.assertEqual(
426+ self.stderr.getvalue(),
427+ "Invalid value for monkey-madness: '0.99999999'\n")
428+ state = yield self.service_state.get_config()
429+ self.assertEqual(
430+ state,
431+ {"awesome": False, "monkey-madness": 0.5,
432+ "query-cache-size": -1, "tuning-level": "safest"})
433+
434+ @inlineCallbacks
435+ def test_valid_options_format_v2(self):
436+ """Verify that config settings can be properly parsed and applied"""
437+ self.service_state = yield self.add_service_from_charm(
438+ "mysql-format-v2")
439+ finished = self.setup_cli_reactor()
440+ self.setup_exit(0)
441+ self.mocker.replay()
442+ main(["set",
443+ "mysql-format-v2",
444+ "query-cache-size=100",
445+ "awesome=true",
446+ "tuning-level=unsafe",
447+ "monkey-madness=0.97"])
448+ yield finished
449+ self.assertEqual(self.stderr.getvalue(), "")
450+ state = yield self.service_state.get_config()
451+ self.assertEqual(
452+ state,
453+ {"awesome": True, "monkey-madness": 0.97,
454+ "query-cache-size": 100, "tuning-level": "unsafe"})
455+
456+ @inlineCallbacks
457+ def test_invalid_float_option_format_v2(self):
458+ """Verify that config settings reject invalid floats"""
459+ self.service_state = yield self.add_service_from_charm(
460+ "mysql-format-v2")
461+ finished = self.setup_cli_reactor()
462+ self.setup_exit(0)
463+ self.mocker.replay()
464+ main(["set", "mysql-format-v2",
465+ "monkey-madness='barrels of monkeys'"])
466+ yield finished
467+ self.assertEqual(
468+ self.stderr.getvalue(),
469+ "Invalid value for monkey-madness: barrels of monkeys\n")
470+ state = yield self.service_state.get_config()
471+ self.assertEqual(
472+ state,
473+ {"awesome": False, "monkey-madness": 0.5,
474+ "query-cache-size": -1, "tuning-level": "safest"})
475+
476+ @inlineCallbacks
477+ def test_invalid_int_option_format_v2(self):
478+ """Verify that config settings reject invalid ints"""
479+ self.service_state = yield self.add_service_from_charm(
480+ "mysql-format-v2")
481+ finished = self.setup_cli_reactor()
482+ self.setup_exit(0)
483+ self.mocker.replay()
484+ main(["set", "mysql-format-v2", "query-cache-size=big"])
485+ yield finished
486+ self.assertEqual(
487+ self.stderr.getvalue(),
488+ "Invalid value for query-cache-size: big\n")
489+ state = yield self.service_state.get_config()
490+ self.assertEqual(
491+ state,
492+ {"awesome": False, "monkey-madness": 0.5,
493+ "query-cache-size": -1, "tuning-level": "safest"})
494+
495+ @inlineCallbacks
496+ def test_invalid_string_option_format_v2(self):
497+ """Verify that config settings reject invalid string"""
498+ self.service_state = yield self.add_service_from_charm(
499+ "mysql-format-v2")
500+ finished = self.setup_cli_reactor()
501+ self.setup_exit(0)
502+ self.mocker.replay()
503+ # YAML does a fair amount of coercion... note that it needs to
504+ # be quoted if it looks like a boolean/int/string
505+ main(["set", "mysql-format-v2", "tuning-level=FALSE"])
506+ yield finished
507+ self.assertEqual(
508+ self.stderr.getvalue(),
509+ "Invalid value for tuning-level: false\n")
510+ state = yield self.service_state.get_config()
511+ self.assertEqual(
512+ state,
513+ {"awesome": False, "monkey-madness": 0.5,
514+ "query-cache-size": -1, "tuning-level": "safest"})
515
516=== modified file 'juju/hooks/cli.py'
517--- juju/hooks/cli.py 2012-05-02 00:49:10 +0000
518+++ juju/hooks/cli.py 2012-06-15 02:00:25 +0000
519@@ -8,8 +8,9 @@
520 from twisted.internet import defer
521 from twisted.internet import protocol
522
523-from juju.errors import JujuError
524 from juju.hooks.protocol import UnitAgentClient
525+from juju.lib.format import get_charm_formatter_from_env
526+
527
528 _marker = object()
529
530@@ -37,7 +38,6 @@
531
532 `require_cid` -- Does the command require the specification of a
533 client_id. (default: True)
534-
535 """
536
537 default_mode = "wb"
538@@ -153,7 +153,8 @@
539 stream=self.options.log_file)
540
541 def parse_kvpairs(self, options):
542- data = parse_keyvalue_pairs(options)
543+ formatter = get_charm_formatter_from_env()
544+ data = formatter.parse_keyvalue_pairs(options)
545 # cache
546 self.options.keyvalue_pairs = data
547 return data
548@@ -226,7 +227,8 @@
549
550 def format_smart(self, result, stream):
551 if result is not None:
552- print >>stream, str(result)
553+ charm_formatter = get_charm_formatter_from_env()
554+ print >>stream, charm_formatter.format(result)
555
556
557 def parse_log_level(level):
558@@ -244,31 +246,6 @@
559 return level
560
561
562-def parse_keyvalue_pairs(options):
563- data = {}
564- for kv in options:
565- if "=" not in kv:
566- raise JujuError(
567- "Expected `option=value`. Found `%s`" % kv)
568-
569- k, v = kv.split("=", 1)
570- if v.startswith("@"):
571- # handle fileinput per spec
572- # XXX: todo -- sanitize input
573- filename = v[1:]
574- try:
575- with open(filename, "r") as f:
576- v = f.read()
577- except IOError:
578- raise JujuError(
579- "No such file or directory: %s (argument:%s)" % (
580- filename,
581- k))
582- data[k] = v
583-
584- return data
585-
586-
587 def parse_port_protocol(port_protocol_string):
588 """Returns (`port`, `protocol`) by converting `port_protocol_string`.
589
590
591=== modified file 'juju/hooks/invoker.py'
592--- juju/hooks/invoker.py 2012-05-01 00:26:25 +0000
593+++ juju/hooks/invoker.py 2012-06-15 02:00:25 +0000
594@@ -148,6 +148,7 @@
595 self._socket_path = socket_path
596 self._unit_path = unit_path
597 self._log = logger
598+ self._charm_format = None
599
600 # The twisted.internet.process.Process instance.
601 self._process = None
602@@ -187,6 +188,14 @@
603 display_parent_relation_ident,
604 sorted(relation_idents)))
605
606+ service = yield self._context.get_local_service()
607+ charm = yield service.get_charm_state()
608+ self._charm_format = (yield charm.get_metadata()).format
609+
610+ @property
611+ def charm_format(self):
612+ return self._charm_format
613+
614 @property
615 def ended(self):
616 return self._ended
617@@ -205,6 +214,7 @@
618 base = dict(JUJU_AGENT_SOCKET=self._socket_path,
619 JUJU_CLIENT_ID=self._client_id,
620 CHARM_DIR=os.path.join(self._unit_path, "charm"),
621+ _JUJU_CHARM_FORMAT=str(self.charm_format),
622 JUJU_UNIT_NAME=os.environ["JUJU_UNIT_NAME"],
623 DEBIAN_FRONTEND="noninteractive",
624 APT_LISTCHANGES_FRONTEND="none",
625
626=== modified file 'juju/hooks/protocol.py'
627--- juju/hooks/protocol.py 2012-04-06 20:29:41 +0000
628+++ juju/hooks/protocol.py 2012-06-15 02:00:25 +0000
629@@ -42,14 +42,16 @@
630
631
632 """
633-import json
634+
635 import logging
636
637 from twisted.internet import defer
638 from twisted.internet import protocol
639 from twisted.protocols import amp
640+import yaml
641
642 from juju.errors import JujuError
643+from juju.lib.format import get_charm_formatter, get_charm_formatter_from_env
644 from juju.state.errors import UnitRelationStateNotFound
645 from juju.state.hook import RelationHookContext
646
647@@ -96,14 +98,15 @@
648 ("relation_id", amp.String()),
649 ("unit_name", amp.String()),
650 ("setting_name", amp.String())]
651- response = [("data", amp.String())]
652+ response = [("data", amp.String()),
653+ ("charm_format", amp.Integer())]
654
655
656 class RelationSetCommand(BaseCommand):
657 commandName = "relation_set"
658 arguments = [("client_id", amp.String()),
659 ("relation_id", amp.String()),
660- ("json_blob", amp.String())]
661+ ("blob", amp.String())]
662 response = []
663
664
665@@ -184,7 +187,8 @@
666
667 @RelationGetCommand.responder
668 @defer.inlineCallbacks
669- def relation_get(self, client_id, relation_id, unit_name, setting_name):
670+ def relation_get(self,
671+ client_id, relation_id, unit_name, setting_name):
672 """Get settings from a state.hook.RelationHookContext
673
674 :param settings_name: optional setting_name (str) indicating that
675@@ -192,11 +196,11 @@
676
677 """
678 context = self.factory.get_context(client_id)
679+ invoker = self.factory.get_invoker(client_id)
680 if relation_id:
681 yield self.factory.log(
682 logging.DEBUG, "Getting relation %s" % relation_id)
683- context = yield self.factory.get_invoker(client_id).\
684- get_relation_hook_context(relation_id)
685+ context = yield invoker.get_relation_hook_context(relation_id)
686 require_relation_context(context)
687
688 try:
689@@ -207,26 +211,32 @@
690 except UnitRelationStateNotFound, e:
691 raise NoSuchUnit(str(e))
692
693- defer.returnValue(dict(data=json.dumps(data)))
694+ formatter = get_charm_formatter(invoker.charm_format)
695+ defer.returnValue(dict(
696+ charm_format=invoker.charm_format,
697+ data=formatter.dump(data)))
698
699 @RelationSetCommand.responder
700 @defer.inlineCallbacks
701- def relation_set(self, client_id, relation_id, json_blob):
702+ def relation_set(self, client_id, relation_id, blob):
703 """Set values into state.hook.RelationHookContext.
704
705- :param json_blob: a JSON serialized string of a dict that will
706+ :param blob: a YAML or JSON dumped string of a dict that will
707 contain the delta of settings to be applied to a unit_name.
708 """
709- data = json.loads(json_blob)
710 context = yield self.factory.get_context(client_id)
711+ invoker = self.factory.get_invoker(client_id)
712+ formatter = get_charm_formatter(invoker.charm_format)
713+ data = formatter.load(blob)
714+
715 if relation_id:
716 yield self.factory.log(
717 logging.DEBUG, "Setting relation %s" % relation_id)
718- context = yield self.factory.get_invoker(client_id).\
719- get_relation_hook_context(relation_id)
720+ context = yield invoker.get_relation_hook_context(relation_id)
721 require_relation_context(context)
722+
723 for k, v in data.items():
724- if not v.strip():
725+ if formatter.should_delete(v):
726 yield context.delete_value(k)
727 else:
728 yield context.set_value(k, v)
729@@ -298,7 +308,9 @@
730 else:
731 options = dict(options)
732
733- defer.returnValue(dict(data=json.dumps(options)))
734+ # NOTE: no need to consider charm format for blob here, this
735+ # blob has always been in YAML format
736+ defer.returnValue(dict(data=yaml.safe_dump(options)))
737
738 @OpenPortCommand.responder
739 @defer.inlineCallbacks
740@@ -380,7 +392,8 @@
741 relation_id=relation_id,
742 unit_name=unit_name,
743 setting_name=setting_name)
744- defer.returnValue(json.loads(result["data"]))
745+ formatter = get_charm_formatter(result["charm_format"])
746+ defer.returnValue(formatter.load(result["data"]))
747
748 @defer.inlineCallbacks
749 def relation_set(self, client_id, relation_id, data):
750@@ -389,11 +402,12 @@
751 :param data: Python dict applied as a delta hook settings
752
753 """
754- json_blob = json.dumps(data)
755+ formatter = get_charm_formatter_from_env()
756+ blob = formatter.dump(data)
757 yield self.callRemote(RelationSetCommand,
758 client_id=client_id,
759 relation_id=relation_id,
760- json_blob=json_blob)
761+ blob=blob)
762 defer.returnValue(None)
763
764 @defer.inlineCallbacks
765@@ -429,7 +443,7 @@
766 client_id=client_id,
767 option_name=option_name)
768 # Unbundle and deserialize
769- result = json.loads(result["data"])
770+ result = yaml.safe_load(result["data"])
771 defer.returnValue(result)
772
773 @defer.inlineCallbacks
774
775=== modified file 'juju/hooks/tests/test_cli.py'
776--- juju/hooks/tests/test_cli.py 2012-03-27 01:04:50 +0000
777+++ juju/hooks/tests/test_cli.py 2012-06-15 02:00:25 +0000
778@@ -1,15 +1,17 @@
779+# -*- encoding: utf-8 -*-
780+
781 import json
782 import logging
783 import os
784 import StringIO
785 from argparse import ArgumentTypeError
786+from contextlib import closing
787
788 from twisted.internet.defer import inlineCallbacks, returnValue
789+import yaml
790
791-from juju.errors import JujuError
792 from juju.hooks.cli import (
793- CommandLineClient, parse_keyvalue_pairs, parse_log_level,
794- parse_port_protocol)
795+ CommandLineClient, parse_log_level, parse_port_protocol)
796 from juju.lib.testing import TestCase
797
798
799@@ -322,30 +324,6 @@
800 self.assertEquals(parse_log_level(logging.INFO), logging.INFO)
801 self.assertEquals(parse_log_level(logging.ERROR), logging.ERROR)
802
803- def test_parse_keyvalue_pairs(self):
804- sample = self.makeFile("INPUT DATA")
805-
806- # test various styles of options being read
807- options = ["alpha=beta",
808- "content=@%s" % sample]
809-
810- data = parse_keyvalue_pairs(options)
811- self.assertEquals(data["alpha"], "beta")
812- self.assertEquals(data["content"], "INPUT DATA")
813-
814- # and check an error condition
815- options = ["content=@missing"]
816- error = self.assertRaises(JujuError, parse_keyvalue_pairs, options)
817- self.assertEquals(
818- str(error),
819- "No such file or directory: missing (argument:content)")
820-
821- # and check when fed non-kvpairs the error makes sense
822- options = ["foobar"]
823- error = self.assertRaises(JujuError, parse_keyvalue_pairs, options)
824- self.assertEquals(
825- str(error), "Expected `option=value`. Found `foobar`")
826-
827 def test_parse_port_protocol(self):
828 self.assertEqual(parse_port_protocol("80"), (80, "tcp"))
829 self.assertEqual(parse_port_protocol("443/tcp"), (443, "tcp"))
830@@ -388,33 +366,96 @@
831 "Invalid protocol, must be 'tcp' or 'udp', "
832 "got 'not-a-valid-protocol'")
833
834- def test_format_smart(self):
835- cli = CommandLineClient()
836-
837- output = StringIO.StringIO()
838- cli.format_smart(str('Basic string'), output)
839- self.assertEqual(output.getvalue(), "Basic string\n")
840- output.close()
841-
842- output = StringIO.StringIO()
843- cli.format_smart(float(1.0), output)
844- self.assertEqual(output.getvalue(), "1.0\n")
845- output.close()
846-
847- output = StringIO.StringIO()
848- cli.format_smart(int(1), output)
849- self.assertEqual(output.getvalue(), "1\n")
850- output.close()
851-
852- # LP bug # 900517 - smart format renders int(0) as an empty
853- # string
854- output = StringIO.StringIO()
855- cli.format_smart(int(0), output)
856- self.assertEqual(output.getvalue(), "0\n")
857- output.close()
858-
859- # None does not even print the \n
860- output = StringIO.StringIO()
861- cli.format_smart(None, output)
862- self.assertEqual(output.getvalue(), "")
863- output.close()
864+ def assert_smart_output_v1(self, sample, formatted=object()):
865+ """Verifies output serialization"""
866+ # No roundtripping is verified because str(obj) is in general
867+ # not roundtrippable
868+ cli = CommandLineClient()
869+ with closing(StringIO.StringIO()) as output:
870+ cli.format_smart(sample, output)
871+ self.assertEqual(output.getvalue(), formatted)
872+
873+ def assert_format_smart_v1(self):
874+ """Verifies legacy smart format v1 which uses Python str encoding"""
875+ self.assert_smart_output_v1(None, "") # No \n in output for None
876+ self.assert_smart_output_v1("", "\n")
877+ self.assert_smart_output_v1("A string", "A string\n")
878+ self.assert_smart_output_v1(
879+ "High bytes: \xca\xfe", "High bytes: \xca\xfe\n")
880+ self.assert_smart_output_v1(u"", "\n")
881+ self.assert_smart_output_v1(
882+ u"A unicode string (but really ascii)",
883+ "A unicode string (but really ascii)\n")
884+ # Maintain LP bug #901495, fixed in v2 format; this happens because
885+ # str(obj) is used
886+ e = self.assertRaises(
887+ UnicodeEncodeError,
888+ self.assert_smart_output_v1, u"中文")
889+ self.assertEqual(
890+ str(e),
891+ ("'ascii' codec can't encode characters in position 0-1: "
892+ "ordinal not in range(128)"))
893+ self.assert_smart_output_v1({}, "{}\n")
894+ self.assert_smart_output_v1(
895+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com"},
896+ "{u'public-address': u'ec2-1-2-3-4.compute-1.amazonaws.com'}\n")
897+ self.assert_smart_output_v1(False, "False\n")
898+ self.assert_smart_output_v1(True, "True\n")
899+ self.assert_smart_output_v1(0.0, "0.0\n")
900+ self.assert_smart_output_v1(3.14159, "3.14159\n")
901+ self.assert_smart_output_v1(6.02214178e23, "6.02214178e+23\n")
902+ self.assert_smart_output_v1(0, "0\n")
903+ self.assert_smart_output_v1(42, "42\n")
904+
905+ def test_format_smart_v1_implied(self):
906+ """Smart format v1 is implied if _JUJU_CHARM_FORMAT is not defined"""
907+ # Double check env setup
908+ self.assertNotIn("_JUJU_CHARM_FORMAT", os.environ)
909+ self.assert_format_smart_v1()
910+
911+ def test_format_smart_v1(self):
912+ """Verify legacy format v1 works"""
913+ self.change_environment(_JUJU_CHARM_FORMAT="1")
914+ self.assert_format_smart_v1()
915+
916+ def assert_smart_output(self, sample, formatted):
917+ cli = CommandLineClient()
918+ with closing(StringIO.StringIO()) as output:
919+ cli.format_smart(sample, output)
920+ self.assertEqual(output.getvalue(), formatted)
921+ self.assertEqual(sample, yaml.safe_load(output.getvalue()))
922+
923+ def test_format_smart_v2(self):
924+ """Verifies smart format v2 writes correct YAML"""
925+ self.change_environment(_JUJU_CHARM_FORMAT="2")
926+
927+ # For each case, verify actual output serialization along with
928+ # roundtripping through YAML
929+ self.assert_smart_output(None, "") # No newline in output for None
930+ self.assert_smart_output("", "''\n")
931+ self.assert_smart_output("A string", "A string\n")
932+ # Note: YAML uses b64 encoding for byte strings tagged by !!binary
933+ self.assert_smart_output(
934+ "High bytes: \xCA\xFE",
935+ "!!binary |\n SGlnaCBieXRlczogyv4=\n")
936+ self.assert_smart_output(u"", "''\n")
937+ self.assert_smart_output(
938+ u"A unicode string (but really ascii)",
939+ "A unicode string (but really ascii)\n")
940+ # Any non-ascii Unicode will use UTF-8 encoding
941+ self.assert_smart_output(u"中文", "\xe4\xb8\xad\xe6\x96\x87\n")
942+ self.assert_smart_output({}, "{}\n")
943+ self.assert_smart_output(
944+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
945+ u"foo": u"bar",
946+ u"configured": True},
947+ ("configured: true\n"
948+ "foo: bar\n"
949+ "public-address: ec2-1-2-3-4.compute-1.amazonaws.com\n"))
950+ self.assert_smart_output(False, "false\n")
951+ self.assert_smart_output(True, "true\n")
952+ self.assert_smart_output(0.0, "0.0\n")
953+ self.assert_smart_output(3.14159, "3.14159\n")
954+ self.assert_smart_output(6.02214178e23, "6.02214178e+23\n")
955+ self.assert_smart_output(0, "0\n")
956+ self.assert_smart_output(42, "42\n")
957
958=== modified file 'juju/hooks/tests/test_communications.py'
959--- juju/hooks/tests/test_communications.py 2012-04-06 20:29:41 +0000
960+++ juju/hooks/tests/test_communications.py 2012-06-15 02:00:25 +0000
961@@ -76,6 +76,12 @@
962 return MockServiceUnitState
963
964
965+class MockInvoker(object):
966+
967+ def __init__(self, charm_format):
968+ self.charm_format = charm_format
969+
970+
971 class UnitSettingsFactoryLocal(UnitSettingsFactory):
972 """
973 For testing a UnitSettingsFactory with local storage. Loosely
974@@ -91,6 +97,7 @@
975 self.members = []
976 self.relation_idents = []
977 self._agent_io = StringIO()
978+ self._invoker = MockInvoker(charm_format=1)
979
980 # hook context and a logger to the settings factory
981 logger = logging.getLogger("unit-settings-fact-test")
982@@ -104,7 +111,7 @@
983 return self
984
985 def invoker(self, client_id):
986- return "mock invoker"
987+ return self._invoker
988
989 def get_value(self, unit_name, setting_name):
990 return self.data[unit_name][setting_name]
991@@ -310,7 +317,7 @@
992 error = MustSpecifyRelationName()
993 self.assertTrue(isinstance(error, JujuError))
994 self.assertEquals(
995- str(error),
996+ str(error),
997 "Relation name must be specified")
998
999 @defer.inlineCallbacks
1000
1001=== modified file 'juju/hooks/tests/test_invoker.py'
1002--- juju/hooks/tests/test_invoker.py 2012-05-04 03:36:36 +0000
1003+++ juju/hooks/tests/test_invoker.py 2012-06-15 02:00:25 +0000
1004@@ -1,3 +1,5 @@
1005+# -*- encoding: utf-8 -*-
1006+
1007 from StringIO import StringIO
1008 import json
1009 import logging
1010@@ -471,6 +473,7 @@
1011
1012 class InvokerTest(RelationInvokerTestBase):
1013
1014+ @defer.inlineCallbacks
1015 def test_environment(self):
1016 """Test various way to manipulate the calling environment.
1017 """
1018@@ -494,11 +497,18 @@
1019 self.assertEqual(env["DEBIAN_FRONTEND"], "noninteractive")
1020 self.assertEqual(env["APT_LISTCHANGES_FRONTEND"], "none")
1021
1022+ # Specific to the charm that is running, in this case it's the
1023+ # dummy charm (this is the default charm used when the
1024+ # add_service method is use)
1025+ self.assertEqual(env["_JUJU_CHARM_FORMAT"], "1")
1026+
1027+ @defer.inlineCallbacks
1028 def test_missing_hook(self):
1029 exe = yield self.ua.get_invoker(
1030 "database:42", "add", "mysql/0", self.relation)
1031 self.failUnlessRaises(errors.FileNotFound, exe, "hook-missing")
1032
1033+ @defer.inlineCallbacks
1034 def test_noexec_hook(self):
1035 exe = yield self.ua.get_invoker(
1036 "database:42", "add", "mysql/0", self.relation)
1037@@ -1080,10 +1090,10 @@
1038 u"b": u"xyz",
1039 "private-address": "mysql-0.example.com"})
1040 yield self.assert_zk_data(db1, {
1041- u"c": u"21",
1042+ u"c": "21",
1043 "private-address": "mysql-0.example.com"})
1044 yield self.assert_zk_data(db2, {
1045- u"d": u"99",
1046+ u"d": "99",
1047 "private-address": "mysql-0.example.com"})
1048 self.assertLogLines(
1049 self.log.getvalue(),
1050@@ -1502,3 +1512,166 @@
1051 self.assertIn("mysql/1", self.log.getvalue())
1052 # we don't see units in the other container
1053 self.assertNotIn("mysql/0", self.log.getvalue())
1054+
1055+
1056+def capture_separate_log(name, level):
1057+ """Support the separate capture of logging at different log levels.
1058+
1059+ TestCase.capture_logging only allows one level to be captured at
1060+ any given time. Given that the hook log captures both data AND
1061+ traditional logging, it's useful to separate.
1062+ """
1063+ logger = logging.getLogger(name)
1064+ output = StringIO()
1065+ handler = logging.StreamHandler(output)
1066+ handler.setLevel(level)
1067+ logger.addHandler(handler)
1068+ return output
1069+
1070+
1071+class TestCharmFormatV1(RelationInvokerTestBase):
1072+
1073+ @defer.inlineCallbacks
1074+ def _default_relations(self):
1075+ """Intercept mysql/wordpress setup to ensure v1 charm format"""
1076+ # The mysql charm in the test repository has no format defined
1077+ yield self.add_service_from_charm("mysql", charm_name="mysql")
1078+ yield super(TestCharmFormatV1, self)._default_relations()
1079+
1080+ @defer.inlineCallbacks
1081+ def test_environment(self):
1082+ """Ensure that an implicit setting of format: 1 works properly"""
1083+ exe = yield self.ua.get_invoker(
1084+ "database:42", "add", "mysql/0", self.relation)
1085+ env = exe.get_environment()
1086+ self.assertEqual(env["_JUJU_CHARM_FORMAT"], "1")
1087+
1088+ @defer.inlineCallbacks
1089+ def test_output(self):
1090+ """Verify roundtripping"""
1091+ hook_debug_log = capture_separate_log("hook", level=logging.DEBUG)
1092+ hook_log = capture_separate_log("hook", level=logging.INFO)
1093+ exe = yield self.ua.get_invoker(
1094+ "database:42", "add", "mysql/0", self.relation,
1095+ client_id="client_id")
1096+ set_hook = self.create_hook(
1097+ "relation-set",
1098+ "b=true i=42 f=1.23 s=ascii u=中文")
1099+ yield exe(set_hook)
1100+ result = yield exe(self.create_hook("relation-get", "- mysql/0"))
1101+ self.assertEqual(result, 0)
1102+
1103+ # No guarantee on output ordering, so keep to this test stable,
1104+ # first a little parsing work:
1105+ output = hook_log.getvalue()
1106+ self.assertEqual(output[0], "{")
1107+ self.assertEqual(output[-3:-1], "}\n")
1108+ self.assertEqual(
1109+ set(output[1:-3].split(", ")),
1110+ set(["u'b': u'true'",
1111+ "u'f': u'1.23'",
1112+ "u'i': u'42'",
1113+ "u'private-address': u'mysql-0.example.com'",
1114+ "u's': u'ascii'",
1115+ "u'u': u'\\u4e2d\\u6587'"]))
1116+
1117+ self.assertLogLines(
1118+ hook_debug_log.getvalue(),
1119+ ["Flushed values for hook %r on 'database:42'" % (
1120+ os.path.basename(set_hook),),
1121+ " Setting changed: u'b'=u'true' (was unset)",
1122+ " Setting changed: u'f'=u'1.23' (was unset)",
1123+ " Setting changed: u'i'=u'42' (was unset)",
1124+ " Setting changed: u's'=u'ascii' (was unset)",
1125+ " Setting changed: u'u'=u'\\u4e2d\\u6587' (was unset)"])
1126+
1127+ # Unlike v2 formatting, this will only fail on output
1128+ hook_log.truncate()
1129+ data_file = self.makeFile("But when I do drink, I prefer \xCA\xFE")
1130+ yield exe(self.create_hook(
1131+ "relation-set", "d=@%s" % data_file))
1132+ result = yield exe(self.create_hook("relation-get", "d mysql/0"))
1133+ self.assertIn(
1134+ "Error: \'utf8\' codec can\'t decode byte 0xca in position 30: "
1135+ "invalid continuation byte",
1136+ hook_log.getvalue())
1137+
1138+
1139+class TestCharmFormatV2(RelationInvokerTestBase):
1140+
1141+ @defer.inlineCallbacks
1142+ def _default_relations(self):
1143+ """Intercept mysql/wordpress setup to ensure v2 charm format"""
1144+ # The mysql-format-v2 charm defines format:2 in its metadata
1145+ yield self.add_service_from_charm(
1146+ "mysql", charm_name="mysql-format-v2")
1147+ yield super(TestCharmFormatV2, self)._default_relations()
1148+
1149+ @defer.inlineCallbacks
1150+ def test_environment(self):
1151+ """Ensure that an explicit setting of format: 2 works properly"""
1152+ exe = yield self.ua.get_invoker(
1153+ "database:42", "add", "mysql/0", self.relation)
1154+ env = exe.get_environment()
1155+ self.assertEqual(env["_JUJU_CHARM_FORMAT"], "2")
1156+
1157+ @defer.inlineCallbacks
1158+ def test_output(self):
1159+ """Verify roundtripping"""
1160+ hook_debug_log = capture_separate_log("hook", level=logging.DEBUG)
1161+ hook_log = capture_separate_log("hook", level=logging.INFO)
1162+ exe = yield self.ua.get_invoker(
1163+ "database:42", "add", "mysql/0", self.relation,
1164+ client_id="client_id")
1165+
1166+ # Byte strings are also supported by staying completely in
1167+ # YAML, so test that corner case. This also means users need
1168+ # to present valid YAML for any input:
1169+ data_file = self.makeFile(
1170+ yaml.safe_dump("But when I do drink, I prefer \xCA\xFE"))
1171+ set_hook = self.create_hook(
1172+ "relation-set",
1173+ "b=true i=42 f=1.23 s=ascii u=中文 d=@%s" % data_file)
1174+ yield exe(set_hook)
1175+ result = yield exe(self.create_hook("relation-get", "- mysql/0"))
1176+ self.assertEqual(result, 0)
1177+
1178+ # YAML guarantees that the keys will be sorted
1179+ # lexicographically; note that we output UTF-8 for Unicode
1180+ # when dumping YAML, so our source text (with this test file
1181+ # in UTF-8 itself) matches the output text, as seen in the
1182+ # characters for "zhongwen" (Chinese language).
1183+ self.assertEqual(
1184+ hook_log.getvalue(),
1185+ "b: true\n"
1186+ "d: !!binary |\n QnV0IHdoZW4gSSBkbyBkcmluaywgSSBwcmVmZXIgyv4=\n"
1187+ "f: 1.23\n"
1188+ "i: 42\n"
1189+ "private-address: mysql-0.example.com\n"
1190+ "s: ascii\n"
1191+ "u: 中文\n\n")
1192+
1193+ # Log lines are not simply converted into Unicode, as in v1 format
1194+ self.assertLogLines(
1195+ hook_debug_log.getvalue(),
1196+ ["Flushed values for hook %r on 'database:42'" % (
1197+ os.path.basename(set_hook),),
1198+ " Setting changed: 'b'=True (was unset)",
1199+ " Setting changed: 'd'='But when I do drink, "
1200+ "I prefer \\xca\\xfe' (was unset)",
1201+ " Setting changed: 'f'=1.23 (was unset)",
1202+ " Setting changed: 'i'=42 (was unset)",
1203+ " Setting changed: 's'='ascii' (was unset)",
1204+ " Setting changed: 'u'=u'\\u4e2d\\u6587' (was unset)"])
1205+
1206+ # Also ensure that invalid YAML is rejected; unlike earlier,
1207+ # this was not encoded with yaml.safe_dump
1208+ data_file = self.makeFile(
1209+ "But when I do drink, I prefer \xCA\xFE")
1210+ hook = self.create_hook("relation-set", "d=@%s" % data_file)
1211+ e = yield self.assertFailure(exe(hook), errors.CharmInvocationError)
1212+ self.assertEqual(str(e), "Error processing %r: exit code 1." % hook)
1213+ self.assertIn(
1214+ "yaml.reader.ReaderError: \'utf8\' codec can\'t decode byte #xca: "
1215+ "invalid continuation byte\n in \"<string>\", position 30",
1216+ hook_log.getvalue())
1217
1218=== added file 'juju/lib/format.py'
1219--- juju/lib/format.py 1970-01-01 00:00:00 +0000
1220+++ juju/lib/format.py 2012-06-15 02:00:25 +0000
1221@@ -0,0 +1,144 @@
1222+"""Utility functions and constants to support uniform i/o formatting."""
1223+
1224+import json
1225+import os
1226+
1227+import yaml
1228+
1229+from juju.errors import JujuError
1230+
1231+
1232+class BaseFormat(object):
1233+ """Maintains parallel code paths for input and output formatting
1234+ through the subclasses Format1 (Python str formatting with JSON
1235+ encoding) and Format2 (YAML format).
1236+ """
1237+
1238+ def parse_keyvalue_pairs(self, pairs):
1239+ """Parses key value pairs, using `_parse_value` for specific format"""
1240+ data = {}
1241+ for kv in pairs:
1242+ if "=" not in kv:
1243+ raise JujuError(
1244+ "Expected `option=value`. Found `%s`" % kv)
1245+
1246+ k, v = kv.split("=", 1)
1247+ if v.startswith("@"):
1248+ # Handle file input, any parsing/sanitization is done next
1249+ # with respect to charm format
1250+ filename = v[1:]
1251+ try:
1252+ with open(filename, "r") as f:
1253+ v = f.read()
1254+ except IOError:
1255+ raise JujuError(
1256+ "No such file or directory: %s (argument:%s)" % (
1257+ filename,
1258+ k))
1259+ except Exception, e:
1260+ raise JujuError("Bad file %s" % e)
1261+
1262+ data[k] = self._parse_value(k, v)
1263+
1264+ return data
1265+
1266+
1267+class Format1(BaseFormat):
1268+ """Supports backwards compatibility through str and JSON encoding."""
1269+
1270+ charm_format = 1
1271+
1272+ def format(self, data):
1273+ """Formats `data` using Python str encoding"""
1274+ return str(data)
1275+
1276+ def _parse_value(self, key, value):
1277+ """Interprets value as a str"""
1278+ return value
1279+
1280+ # For the old format: 1, using JSON serialization introduces some
1281+ # subtle issues around Unicode conversion that then later results
1282+ # in bugginess. For compatibility reasons, we keep these old bugs
1283+ # around, by dumping and loading into JSON at appropriate points.
1284+
1285+ def dump(self, data):
1286+ """Dumps using JSON serialization"""
1287+ return json.dumps(data)
1288+
1289+ def load(self, data):
1290+ """Loads data, but also converts str to Unicode"""
1291+ return json.loads(data)
1292+
1293+ def should_delete(self, value):
1294+ """Whether `value` implies corresponding key should be deleted"""
1295+ # In format: 1, all values are strings, but possibly with
1296+ # spaces. The strip reduces strings consisting only of spaces,
1297+ # or otherwise empty, to an empty string.
1298+ return not value.strip()
1299+
1300+
1301+class Format2(BaseFormat):
1302+ """New format that uses YAML, so no unexpected encoding issues"""
1303+
1304+ charm_format = 2
1305+
1306+ def format(self, data):
1307+ """Formats `data` in Juju's preferred YAML format"""
1308+ # Return value such that it roundtrips; this allows us to
1309+ # report back the boolean false instead of the Python
1310+ # output format, False
1311+ if data is None:
1312+ return ""
1313+ serialized = yaml.safe_dump(
1314+ data, indent=4, default_flow_style=False, width=80,
1315+ allow_unicode=True)
1316+ if serialized.endswith("\n...\n"):
1317+ # Remove explicit doc end sentinel, still valid yaml
1318+ serialized = serialized[0:-5]
1319+ # Also remove any extra \n, will still be valid yaml
1320+ return serialized.rstrip("\n")
1321+
1322+ def _parse_value(self, key, value):
1323+ # Ensure roundtripping to/from yaml if format: 2; in
1324+ # particular this ensures that true/false work as desired
1325+ try:
1326+ return yaml.safe_load(value)
1327+ except Exception:
1328+ raise JujuError("Invalid YAML value (argument:%s)" % key)
1329+
1330+ # Use the same format for dump
1331+ dump = format
1332+
1333+ def load(self, data):
1334+ """Loads data safely, ensuring no Python specific type info leaks"""
1335+ return yaml.safe_load(data)
1336+
1337+ def should_delete(self, value):
1338+ """Whether `value` implies corresponding key should be deleted"""
1339+ # In format: 2, values were already parsed by yaml.safe_load;
1340+ # in particular, a string consisting only of whitespace is
1341+ # parsed as None.
1342+ return value is None
1343+
1344+
1345+def is_valid_charm_format(charm_format):
1346+ """True if `charm_format` is a valid format"""
1347+ return charm_format in (Format1.charm_format, Format2.charm_format)
1348+
1349+
1350+def get_charm_formatter(charm_format):
1351+ """Map `charm_format` to the implementing strategy for that format"""
1352+ if charm_format == Format1.charm_format:
1353+ return Format1()
1354+ elif charm_format == Format2.charm_format:
1355+ return Format2()
1356+ else:
1357+ raise JujuError(
1358+ "Expected charm format to be either 1 or 2, got %s" % (
1359+ charm_format,))
1360+
1361+
1362+def get_charm_formatter_from_env():
1363+ """Return the formatter specified by $_JUJU_CHARM_FORMAT"""
1364+ return get_charm_formatter(int(
1365+ os.environ.get("_JUJU_CHARM_FORMAT", "1")))
1366
1367=== added file 'juju/lib/tests/test_format.py'
1368--- juju/lib/tests/test_format.py 1970-01-01 00:00:00 +0000
1369+++ juju/lib/tests/test_format.py 2012-06-15 02:00:25 +0000
1370@@ -0,0 +1,330 @@
1371+# -*- encoding: utf-8 -*-
1372+
1373+import json
1374+
1375+import yaml
1376+
1377+from juju.errors import JujuError
1378+from juju.lib.testing import TestCase
1379+from juju.lib.format import (
1380+ Format1, Format2, is_valid_charm_format, get_charm_formatter,
1381+ get_charm_formatter_from_env)
1382+
1383+
1384+class TestFormatLookup(TestCase):
1385+
1386+ def test_is_valid_charm_format(self):
1387+ """Verify currently valid charm formats"""
1388+ self.assertFalse(is_valid_charm_format(0))
1389+ self.assertTrue(is_valid_charm_format(1))
1390+ self.assertTrue(is_valid_charm_format(2))
1391+ self.assertFalse(is_valid_charm_format(3))
1392+
1393+ def test_get_charm_formatter(self):
1394+ self.assertInstance(get_charm_formatter(1), Format1)
1395+ self.assertInstance(get_charm_formatter(2), Format2)
1396+ e = self.assertRaises(JujuError, get_charm_formatter, 0)
1397+ self.assertEqual(
1398+ str(e),
1399+ "Expected charm format to be either 1 or 2, got 0")
1400+
1401+ def test_get_charm_formatter_from_env(self):
1402+ """Verifies _JUJU_CHARM_FORMAT can be mapped to valid formatters"""
1403+
1404+ self.change_environment(_JUJU_CHARM_FORMAT="0")
1405+ e = self.assertRaises(JujuError, get_charm_formatter_from_env)
1406+ self.assertEqual(
1407+ str(e),
1408+ "Expected charm format to be either 1 or 2, got 0")
1409+
1410+ self.change_environment(_JUJU_CHARM_FORMAT="1")
1411+ self.assertInstance(get_charm_formatter_from_env(), Format1)
1412+
1413+ self.change_environment(_JUJU_CHARM_FORMAT="2")
1414+ self.assertInstance(get_charm_formatter_from_env(), Format2)
1415+
1416+ self.change_environment(_JUJU_CHARM_FORMAT="3")
1417+ e = self.assertRaises(JujuError, get_charm_formatter_from_env)
1418+ self.assertEqual(
1419+ str(e),
1420+ "Expected charm format to be either 1 or 2, got 3")
1421+
1422+
1423+class TestFormat1(TestCase):
1424+
1425+ def assert_format(self, data, expected):
1426+ """Verify str output serialization; no roundtripping is supported."""
1427+ formatted = Format1().format(data)
1428+ self.assertEqual(formatted, expected)
1429+
1430+ def test_format(self):
1431+ """Verifies Python str formatting of data"""
1432+ self.assert_format(None, "None")
1433+ self.assert_format("", "")
1434+ self.assert_format("A string", "A string")
1435+ self.assert_format(
1436+ "High bytes: \xCA\xFE",
1437+ "High bytes: \xca\xfe")
1438+ self.assert_format(u"", "")
1439+ self.assert_format(
1440+ u"A unicode string (but really ascii)",
1441+ "A unicode string (but really ascii)")
1442+ e = self.assertRaises(UnicodeEncodeError, Format1().format, u"中文")
1443+ self.assertEqual(
1444+ str(e),
1445+ ("'ascii' codec can't encode characters in position 0-1: "
1446+ "ordinal not in range(128)"))
1447+ self.assert_format({}, "{}")
1448+ self.assert_format(
1449+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
1450+ u"foo": u"bar",
1451+ u"configured": True},
1452+ ("{u'public-address': u'ec2-1-2-3-4.compute-1.amazonaws.com', "
1453+ "u'foo': u'bar', u'configured': True}"))
1454+ self.assert_format(False, "False")
1455+ self.assert_format(True, "True")
1456+ self.assert_format(0.0, "0.0")
1457+ self.assert_format(3.14159, "3.14159")
1458+ self.assert_format(6.02214178e23, "6.02214178e+23")
1459+ self.assert_format(0, "0")
1460+ self.assert_format(42, "42")
1461+
1462+ def test_parse_keyvalue_pairs(self):
1463+ """Verify reads in values as strings"""
1464+ sample = self.makeFile("INPUT DATA")
1465+
1466+ # test various styles of options being read
1467+ options = ["alpha=beta",
1468+ "content=@%s" % sample]
1469+
1470+ formatter = Format1()
1471+ data = formatter.parse_keyvalue_pairs(options)
1472+ self.assertEquals(data["alpha"], "beta")
1473+ self.assertEquals(data["content"], "INPUT DATA")
1474+
1475+ # and check an error condition
1476+ options = ["content=@missing"]
1477+ error = self.assertRaises(
1478+ JujuError, formatter.parse_keyvalue_pairs, options)
1479+ self.assertEquals(
1480+ str(error),
1481+ "No such file or directory: missing (argument:content)")
1482+
1483+ # and check when fed non-kvpairs the error makes sense
1484+ options = ["foobar"]
1485+ error = self.assertRaises(
1486+ JujuError, formatter.parse_keyvalue_pairs, options)
1487+ self.assertEquals(
1488+ str(error), "Expected `option=value`. Found `foobar`")
1489+
1490+ def assert_dump_load(self, data, expected):
1491+ """Asserts expected formatting and roundtrip through dump/load"""
1492+ formatter = Format1()
1493+ dumped = formatter.dump({"data": data})
1494+ loaded = formatter.load(dumped)["data"]
1495+ self.assertEqual(dumped, expected)
1496+ self.assertEqual(dumped, json.dumps({"data": data}))
1497+ self.assertEqual(data, loaded)
1498+ if isinstance(data, str):
1499+ # Verify promotion of str to unicode
1500+ self.assertInstance(loaded, unicode)
1501+
1502+ def test_dump_load(self):
1503+ """Verify JSON roundtrip semantics"""
1504+ self.assert_dump_load(None, '{"data": null}')
1505+ self.assert_dump_load("", '{"data": ""}')
1506+ self.assert_dump_load("A string", '{"data": "A string"}')
1507+ e = self.assertRaises(
1508+ UnicodeDecodeError,
1509+ Format1().dump, "High bytes: \xCA\xFE")
1510+ self.assertEqual(
1511+ str(e),
1512+ "'utf8' codec can't decode byte 0xca in position 12: "
1513+ "invalid continuation byte")
1514+ self.assert_dump_load(u"", '{"data": ""}')
1515+ self.assert_dump_load(
1516+ u"A unicode string (but really ascii)",
1517+ '{"data": "A unicode string (but really ascii)"}')
1518+ e = self.assertRaises(UnicodeEncodeError, Format1().format, u"中文")
1519+ self.assertEqual(
1520+ str(e),
1521+ ("'ascii' codec can't encode characters in position 0-1: "
1522+ "ordinal not in range(128)"))
1523+ self.assert_dump_load({}, '{"data": {}}')
1524+ self.assert_dump_load(
1525+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
1526+ u"foo": u"bar",
1527+ u"configured": True},
1528+ ('{"data": {"public-address": '
1529+ '"ec2-1-2-3-4.compute-1.amazonaws.com", "foo": "bar", '
1530+ '"configured": true}}'))
1531+ self.assert_dump_load(False, '{"data": false}')
1532+ self.assert_dump_load(True, '{"data": true}')
1533+ self.assert_dump_load(0.0, '{"data": 0.0}')
1534+ self.assert_dump_load(3.14159, '{"data": 3.14159}')
1535+ self.assert_dump_load(6.02214178e23, '{"data": 6.02214178e+23}')
1536+ self.assert_dump_load(0, '{"data": 0}')
1537+ self.assert_dump_load(42, '{"data": 42}')
1538+
1539+ def test_should_delete(self):
1540+ """Verify empty or whitespace only strings indicate deletion"""
1541+ formatter = Format1()
1542+ self.assertFalse(formatter.should_delete("0"))
1543+ self.assertFalse(formatter.should_delete("something"))
1544+ self.assertTrue(formatter.should_delete(""))
1545+ self.assertTrue(formatter.should_delete(" "))
1546+
1547+ # Verify that format: 1 can only work with str values
1548+ e = self.assertRaises(AttributeError, formatter.should_delete, 42)
1549+ self.assertEqual(str(e), "'int' object has no attribute 'strip'")
1550+ e = self.assertRaises(AttributeError, formatter.should_delete, None)
1551+ self.assertEqual(str(e), "'NoneType' object has no attribute 'strip'")
1552+
1553+
1554+class TestFormat2(TestCase):
1555+
1556+ def assert_format(self, data, expected):
1557+ """Verify actual output serialization and roundtripping through YAML"""
1558+ formatted = Format2().format(data)
1559+ self.assertEqual(formatted, expected)
1560+ self.assertEqual(data, yaml.safe_load(formatted))
1561+
1562+ def test_format(self):
1563+ """Verifies standard formatting of data into valid YAML"""
1564+ self.assert_format(None, "")
1565+ self.assert_format("", "''")
1566+ self.assert_format("A string", "A string")
1567+ # Note: YAML uses b64 encoding for byte strings tagged by !!binary
1568+ self.assert_format(
1569+ "High bytes: \xCA\xFE",
1570+ "!!binary |\n SGlnaCBieXRlczogyv4=")
1571+ self.assert_format(u"", "''")
1572+ self.assert_format(
1573+ u"A unicode string (but really ascii)",
1574+ "A unicode string (but really ascii)")
1575+ # Any non-ascii Unicode will use UTF-8 encoding
1576+ self.assert_format(u"中文", "\xe4\xb8\xad\xe6\x96\x87")
1577+ self.assert_format({}, "{}")
1578+ self.assert_format(
1579+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
1580+ u"foo": u"bar",
1581+ u"configured": True},
1582+ ("configured: true\n"
1583+ "foo: bar\n"
1584+ "public-address: ec2-1-2-3-4.compute-1.amazonaws.com"))
1585+ self.assert_format(False, "false")
1586+ self.assert_format(True, "true")
1587+ self.assert_format(0.0, "0.0")
1588+ self.assert_format(3.14159, "3.14159")
1589+ self.assert_format(6.02214178e23, "6.02214178e+23")
1590+ self.assert_format(0, "0")
1591+ self.assert_format(42, "42")
1592+
1593+ def assert_parse(self, data):
1594+ """Verify input parses as expected, including from a data file"""
1595+ formatter = Format2()
1596+ formatted = formatter.format(data)
1597+ data_file = self.makeFile(formatted)
1598+ kvs = ["formatted=%s" % formatted,
1599+ "file=@%s" % data_file]
1600+ parsed = formatter.parse_keyvalue_pairs(kvs)
1601+ self.assertEqual(parsed["formatted"], data)
1602+ self.assertEqual(parsed["file"], data)
1603+
1604+ def test_parse_keyvalue_pairs(self):
1605+ """Verify key value pairs parse for a wide range of YAML inputs."""
1606+ formatter = Format2()
1607+ self.assert_parse(None)
1608+ self.assert_parse("")
1609+ self.assert_parse("A string")
1610+ self.assert_parse("High bytes: \xCA\xFE")
1611+ self.assert_parse(u"")
1612+ self.assert_parse(u"A unicode string (but really ascii)")
1613+ self.assert_parse(u"中文")
1614+ self.assert_parse({})
1615+ self.assert_parse(
1616+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
1617+ u"foo": u"bar",
1618+ u"configured": True})
1619+ self.assert_parse([])
1620+ self.assert_parse(["abc", "xyz", 42, True])
1621+ self.assert_parse(False)
1622+ self.assert_parse(True)
1623+ self.assert_parse(0.0)
1624+ self.assert_parse(3.14159)
1625+ self.assert_parse(6.02214178e23)
1626+ self.assert_parse(0)
1627+ self.assert_parse(42)
1628+
1629+ # Raises an error if no such file
1630+ e = self.assertRaises(
1631+ JujuError,
1632+ formatter.parse_keyvalue_pairs, ["content=@missing"])
1633+ self.assertEquals(
1634+ str(e),
1635+ "No such file or directory: missing (argument:content)")
1636+
1637+ # Raises an error if not of the form K=V or K=
1638+ e = self.assertRaises(
1639+ JujuError,
1640+ formatter.parse_keyvalue_pairs, ["foobar"])
1641+ self.assertEquals(
1642+ str(e), "Expected `option=value`. Found `foobar`")
1643+
1644+ # Raises an error if the value is invalid YAML
1645+ e = self.assertRaises(
1646+ JujuError,
1647+ formatter.parse_keyvalue_pairs, ["content=\xCA\FE"])
1648+ self.assertEquals(
1649+ str(e),
1650+ "Invalid YAML value (argument:content)")
1651+
1652+ def assert_dump_load(self, data, expected):
1653+ """Asserts expected formatting and roundtrip through dump/load"""
1654+ formatter = Format2()
1655+ dumped = formatter.dump({"data": data})
1656+ loaded = formatter.load(dumped)["data"]
1657+ self.assertEqual(dumped, expected)
1658+ self.assertEqual(data, loaded)
1659+ if isinstance(data, str):
1660+ # Verify that no promotion to unicode occurs for str values
1661+ self.assertInstance(loaded, str)
1662+
1663+ def test_dump_load(self):
1664+ """Verify JSON roundtrip semantics"""
1665+ self.assert_dump_load(None, "data: null")
1666+ self.assert_dump_load("", "data: ''")
1667+ self.assert_dump_load("A string", "data: A string")
1668+ self.assert_dump_load("High bytes: \xCA\xFE",
1669+ "data: !!binary |\n SGlnaCBieXRlczogyv4=")
1670+ self.assert_dump_load(u"", "data: ''")
1671+ self.assert_dump_load(
1672+ u"A unicode string (but really ascii)",
1673+ "data: A unicode string (but really ascii)")
1674+ self.assert_dump_load(u"中文", "data: \xe4\xb8\xad\xe6\x96\x87")
1675+ self.assert_dump_load({}, "data: {}")
1676+ self.assert_dump_load(
1677+ {u"public-address": u"ec2-1-2-3-4.compute-1.amazonaws.com",
1678+ u"foo": u"bar",
1679+ u"configured": True},
1680+ ("data:\n"
1681+ " configured: true\n"
1682+ " foo: bar\n"
1683+ " public-address: ec2-1-2-3-4.compute-1.amazonaws.com"))
1684+ self.assert_dump_load(False, "data: false")
1685+ self.assert_dump_load(True, "data: true")
1686+ self.assert_dump_load(0.0, "data: 0.0")
1687+ self.assert_dump_load(3.14159, "data: 3.14159")
1688+ self.assert_dump_load(6.02214178e23, "data: 6.02214178e+23")
1689+ self.assert_dump_load(0, "data: 0")
1690+ self.assert_dump_load(42, "data: 42")
1691+
1692+ def test_should_delete(self):
1693+ """Verify only `None` values (as YAML loaded) indicate deletion"""
1694+ formatter = Format2()
1695+ self.assertFalse(formatter.should_delete("0"))
1696+ self.assertFalse(formatter.should_delete("something"))
1697+ self.assertFalse(formatter.should_delete(""))
1698+ self.assertFalse(formatter.should_delete(" "))
1699+ self.assertFalse(formatter.should_delete(0))
1700+ self.assertTrue(formatter.should_delete(None))
1701
1702=== modified file 'juju/state/tests/test_relation.py'
1703--- juju/state/tests/test_relation.py 2012-05-15 19:07:47 +0000
1704+++ juju/state/tests/test_relation.py 2012-06-15 02:00:25 +0000
1705@@ -12,6 +12,7 @@
1706 from juju.charm.directory import CharmDirectory
1707 from juju.charm.tests import local_charm_id
1708 from juju.machine.tests.test_constraints import dummy_constraints
1709+from juju.charm.tests.test_metadata import test_repository_path
1710 from juju.charm.tests.test_repository import unbundled_repository
1711 from juju.lib.pick import pick_attr
1712 from juju.state.charm import CharmStateManager
1713@@ -52,6 +53,26 @@
1714 returnValue(service_state)
1715
1716 @inlineCallbacks
1717+ def add_service_from_charm(
1718+ self, service_name, charm_id=None, constraints=None,
1719+ charm_dir=None, charm_name=None):
1720+ """Add a service from a charm.
1721+ """
1722+ if not charm_id and charm_dir is None:
1723+ charm_name = charm_name or service_name
1724+ charm_dir = CharmDirectory(os.path.join(
1725+ test_repository_path, "series", charm_name))
1726+ if charm_id is None:
1727+ charm_state = yield self.charm_manager.add_charm_state(
1728+ local_charm_id(charm_dir), charm_dir, "")
1729+ else:
1730+ charm_state = yield self.charm_manager.get_charm_state(
1731+ charm_id)
1732+ service_state = yield self.service_manager.add_service_state(
1733+ service_name, charm_state, constraints or dummy_constraints)
1734+ returnValue(service_state)
1735+
1736+ @inlineCallbacks
1737 def add_relation(self, relation_type, *services):
1738 """Support older tests that don't use `RelationEndpoint`s"""
1739 endpoints = []
1740@@ -471,8 +492,9 @@
1741 "logging", "juju-info", "juju-info", "client", "container")
1742 yield self.add_service("mysql")
1743 yield self.add_service("logging")
1744- relation_state, service_states = yield self.relation_manager.add_relation_state(
1745- mysql_ep, logging_ep)
1746+ relation_state, service_states = \
1747+ yield self.relation_manager.add_relation_state(mysql_ep,
1748+ logging_ep)
1749
1750 # verify that the relation state instances are both marked
1751 # with the live scope (container). This happens even though
1752@@ -488,8 +510,9 @@
1753 "logging", "juju-info", "juju-info", "client", "container")
1754 yield self.add_service("mysql")
1755 yield self.add_service("logging")
1756- relation_state, service_states = yield self.relation_manager.add_relation_state(
1757- mysql_ep, logging_ep)
1758+ relation_state, service_states = \
1759+ yield self.relation_manager.add_relation_state(mysql_ep,
1760+ logging_ep)
1761
1762 for service_state in service_states:
1763 self.assertEqual(service_state.relation_scope, "container")
1764@@ -502,8 +525,9 @@
1765 "logging", "juju-info", "juju-info", "client", "global")
1766 yield self.add_service("mysql")
1767 yield self.add_service("logging")
1768- relation_state, service_states = yield self.relation_manager.add_relation_state(
1769- mysql_ep, logging_ep)
1770+ relation_state, service_states = \
1771+ yield self.relation_manager.add_relation_state(mysql_ep,
1772+ logging_ep)
1773
1774 for service_state in service_states:
1775 self.assertEqual(service_state.relation_scope, "container")
1776@@ -693,7 +717,8 @@
1777 id = "relation-0000000000"
1778 self.assertEqual(
1779 repr(self.service1_relation),
1780- "<ServiceRelationState name:dev-connect role:prod id:%s scope:global>" % id)
1781+ "<ServiceRelationState name:dev-connect "
1782+ "role:prod id:%s scope:global>" % id)
1783
1784 def test_property_relation_name(self):
1785 """

Subscribers

People subscribed via source and target branches

to status/vote changes: