Merge lp:~jimbaker/pyjuju/charm-format-2 into lp:pyjuju
- charm-format-2
- Merge into trunk
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 |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Juju Engineering | Pending | ||
Review via email: mp+108831@code.launchpad.net |
Commit message
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-
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-
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-
``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.
Jim Baker (jimbaker) wrote : | # |
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.
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?
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.
William Reade (fwereade) wrote : | # |
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:/
File juju/charm/
https:/
juju/charm/
(PYTHON_FORMAT, YAML_FORMAT)?
https:/
File juju/control/
https:/
juju/control/
str(charm_format)
This feels like a strange way to go about modifying the behaviour of
parse_keyvalue_
https:/
File juju/hooks/cli.py (right):
https:/
juju/hooks/
I feel like these should probably be somewhere else, if only because I
see late imports here and there. Suggestion: they, and
parse_keyvalue_
juju.charm?
https:/
juju/hooks/
Appears to be duplicated in juju/charm/
https:/
juju/hooks/
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:/
juju/hooks/
_JUJU_CHARM_FORMAT
https:/
juju/hooks/
charm_format should be a parameter; it's easily accessible to both
clients.
https:/
File juju/hooks/
https:/
juju/hooks/
Add an accessor so the UnitAgentServer can get at this value.
https:/
juju/hooks/
+1 on _JUJU_CHARM_FORMAT as suggested elsewhere. (note: it won't be
necessary in the go port.)
https:/
File juju/hooks/
https:/
juju/hooks/
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.
https:/
juju/hooks/
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).
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?
Jim Baker (jimbaker) wrote : | # |
Please take a look.
https:/
File juju/charm/
https:/
juju/charm/
On 2012/06/06 13:34:40, fwereade wrote:
> (PYTHON_FORMAT, YAML_FORMAT)?
Done.
https:/
File juju/control/
https:/
juju/control/
str(charm_format)
Agreed, just calls the refactored version of parse_keyvalue_
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:/
File juju/hooks/cli.py (right):
https:/
juju/hooks/
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:/
juju/hooks/
Created juju.lib.format and moved this functionality there so it can be
reused as suggested.
https:/
juju/hooks/
That won't work, it's also needed by juju.hooks.protocol
https:/
juju/hooks/
On 2012/06/06 13:34:40, fwereade wrote:
> _JUJU_CHARM_FORMAT
Done.
https:/
juju/hooks/
On 2012/06/06 13:34:40, fwereade wrote:
> charm_format should be a parameter; it's easily accessible to both
clients.
Done.
https:/
File juju/hooks/
https:/
juju/hooks/
On 2012/06/06 13:34:40, fwereade wrote:
> Add an accessor so the UnitAgentServer can get at this value.
Done.
https:/
juju/hooks/
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:/
File juju/hooks/
https:/
juju/hooks/
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.
- 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
Jim Baker (jimbaker) wrote : | # |
Comments addressed in the latest version of the proposal.
Jim Baker (jimbaker) wrote : | # |
Yes, it's now there.
Kapil Thangavelu (hazmat) wrote : | # |
http://
File juju/charm/
http://
juju/charm/
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://
File juju/hooks/
http://
juju/hooks/
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://
File juju/hooks/utils.py (right):
http://
juju/hooks/
os.environ.
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.
Jim Baker (jimbaker) wrote : | # |
Please take a look.
- 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
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://
File juju/charm/
http://
juju/charm/
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://
File juju/hooks/
http://
juju/hooks/
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://
File juju/hooks/utils.py (right):
http://
juju/hooks/
os.environ.
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.
William Reade (fwereade) wrote : | # |
Damn, I'm sorry, I had an unpublished LGTM sitting here for days :(((.
https:/
File juju/hooks/
https:/
juju/hooks/
Heh, nice catches :).
https:/
File juju/lib/format.py (right):
https:/
juju/lib/
Thanks, all this is much clearer.
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:/
File juju/charm/
https:/
juju/charm/
KeyDict, Dict, String,
please call this yamlformat, format2 is not what the output format is.
https:/
File juju/charm/
https:/
juju/charm/
minor, a pointer to a reference on what these entail would be useful
here.
https:/
File juju/hooks/
https:/
juju/hooks/
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.
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:/
> File juju/charm/
https:/
> juju/charm/
KeyDict, Dict,
> String,
> please call this yamlformat, format2 is not what the output format is.
https:/
> File juju/charm/
https:/
> juju/charm/
> minor, a pointer to a reference on what these entail would be useful
here.
https:/
> File juju/hooks/
https:/
> juju/hooks/
> 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.
Jim Baker (jimbaker) wrote : | # |
Responded to Kapil's comments.
https:/
File juju/charm/
https:/
juju/charm/
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:/
File juju/charm/
https:/
juju/charm/
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:/
File juju/hooks/
https:/
juju/hooks/
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.
(changed to TestPythonFormat). William raised the same issue in his
report, and we discussed on IRC earlier. See
https:/
and https:/
would make the unicode promotion from str go away. That's a nice thing,
but bugginess is what we are trying to preserve.
https:/
File juju/hooks/
https:/
juju/hooks/
On 2012/06/18 22:46:41, fwereade wrote:
> Heh, nice catches :).
Thanks!
https:/
File juju/lib/format.py (right):
https:/
juju/lib/
On 2012/06/18 22:46:41, fwereade wrote:
> Thanks, all this is much clearer.
Cool!
Preview Diff
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 | """ |
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 3-4.compute- 1....
foo: bar
public-address: ec2-1-2-