Indent block sequences in emitted files
[mudpy.git] / mudpy / data.py
1 """Data interface functions for the mudpy engine."""
2
3 # Copyright (c) 2004-2017 Jeremy Stanley <fungi@yuggoth.org>. Permission
4 # to use, copy, modify, and distribute this software is granted under
5 # terms provided in the LICENSE file distributed with this software.
6
7 import os
8 import re
9 import stat
10
11 import mudpy
12 import yaml
13
14
15 class _IBSEmitter(yaml.emitter.Emitter):
16
17     """Override the default YAML Emitter to indent block sequences."""
18
19     def expect_block_sequence(self):
20         """Match the expectations of the ``yamllint`` style checker."""
21
22         # TODO(fungi) Get an option for this implemented upstream in
23         # the pyyaml library
24         self.increase_indent(flow=False, indentless=False)
25         self.state = self.expect_first_block_sequence_item
26
27
28 class _IBSDumper(yaml.SafeDumper, _IBSEmitter):
29
30     """Use our _IBSEmitter instead of the default implementation."""
31
32     pass
33
34
35 class Data:
36
37     """A file containing universe elements and their facets."""
38
39     def __init__(self,
40                  source,
41                  universe,
42                  flags=None,
43                  relative=None,
44                  ):
45         self.source = source
46         self.universe = universe
47         if flags is None:
48             self.flags = []
49         else:
50             self.flags = flags[:]
51         self.relative = relative
52         self.load()
53
54     def load(self):
55         """Read a file, create elements and poplulate facets accordingly."""
56         self.modified = False
57         self.source = find_file(
58                 self.source, relative=self.relative, universe=self.universe)
59         try:
60             self.data = yaml.safe_load(open(self.source))
61         except FileNotFoundError:
62             # it's normal if the file is one which doesn't exist yet
63             self.data = {}
64             log_entry = ("File %s is unavailable." % self.source, 6)
65             try:
66                 mudpy.misc.log(*log_entry)
67             except NameError:
68                 # happens when we're not far enough along in the init process
69                 self.universe.setup_loglines.append(log_entry)
70         if not hasattr(self.universe, "files"):
71             self.universe.files = {}
72         self.universe.files[self.source] = self
73         includes = []
74         for node in list(self.data):
75             if node == "_load":
76                 for included in self.data["_load"]:
77                     included = find_file(
78                         included,
79                         relative=self.source,
80                         universe=self.universe)
81                     if included not in includes:
82                         includes.append(included)
83                 continue
84             if node.startswith("_"):
85                 continue
86             facet_pos = node.rfind(".") + 1
87             prefix = node[:facet_pos].strip(".")
88             try:
89                 element = self.universe.contents[prefix]
90             except KeyError:
91                 element = mudpy.misc.Element(prefix, self.universe, self)
92             element.set(node[facet_pos:], self.data[node])
93             if prefix.startswith("mudpy.movement."):
94                 self.universe.directions.add(
95                     prefix[prefix.rfind(".") + 1:])
96         for include_file in includes:
97             if not os.path.isabs(include_file):
98                 include_file = find_file(
99                     include_file,
100                     relative=self.source,
101                     universe=self.universe
102                 )
103             if (include_file not in self.universe.files or not
104                     self.universe.files[include_file].is_writeable()):
105                 Data(include_file, self.universe)
106
107     def save(self):
108         """Write the data, if necessary."""
109         normal_umask = 0o0022
110         private_umask = 0o0077
111         private_file_mode = 0o0600
112
113         # when modified, writeable and has content or the file exists
114         if self.modified and self.is_writeable() and (
115            self.data or os.path.exists(self.source)
116            ):
117
118             # make parent directories if necessary
119             if not os.path.exists(os.path.dirname(self.source)):
120                 old_umask = os.umask(normal_umask)
121                 os.makedirs(os.path.dirname(self.source))
122                 os.umask(old_umask)
123
124             # backup the file
125             if "mudpy.limit" in self.universe.contents:
126                 max_count = self.universe.contents["mudpy.limit"].get(
127                     "backups", 0)
128             else:
129                 max_count = 0
130             if os.path.exists(self.source) and max_count:
131                 backups = []
132                 for candidate in os.listdir(os.path.dirname(self.source)):
133                     if re.match(
134                        os.path.basename(self.source) +
135                        r"""\.\d+$""", candidate
136                        ):
137                         backups.append(int(candidate.split(".")[-1]))
138                 backups.sort()
139                 backups.reverse()
140                 for old_backup in backups:
141                     if old_backup >= max_count - 1:
142                         os.remove(self.source + "." + str(old_backup))
143                     elif not os.path.exists(
144                         self.source + "." + str(old_backup + 1)
145                     ):
146                         os.rename(
147                             self.source + "." + str(old_backup),
148                             self.source + "." + str(old_backup + 1)
149                         )
150                 if not os.path.exists(self.source + ".0"):
151                     os.rename(self.source, self.source + ".0")
152
153             # our data file
154             if "private" in self.flags:
155                 old_umask = os.umask(private_umask)
156                 file_descriptor = open(self.source, "w")
157                 if oct(stat.S_IMODE(os.stat(
158                         self.source)[stat.ST_MODE])) != private_file_mode:
159                     # if it's marked private, chmod it appropriately
160                     os.chmod(self.source, private_file_mode)
161             else:
162                 old_umask = os.umask(normal_umask)
163                 file_descriptor = open(self.source, "w")
164             os.umask(old_umask)
165
166             # write and close the file
167             yaml.dump(self.data, Dumper=_IBSDumper, allow_unicode=True,
168                       default_flow_style=False, explicit_start=True, indent=4,
169                       stream=file_descriptor)
170             file_descriptor.close()
171
172             # unset the modified flag
173             self.modified = False
174
175     def is_writeable(self):
176         """Returns True if the _lock is False."""
177         try:
178             return not self.data.get("_lock", False)
179         except KeyError:
180             return True
181
182
183 def find_file(
184     file_name=None,
185     group=None,
186     prefix=None,
187     relative=None,
188     search=None,
189     stash=None,
190     universe=None
191 ):
192     """Return an absolute file path based on configuration."""
193
194     # this is all unnecessary if it's already absolute
195     if file_name and os.path.isabs(file_name):
196         return os.path.realpath(file_name)
197
198     # if a universe was provided, try to get some defaults from there
199     if universe:
200
201         if hasattr(
202                 universe, "contents") and "mudpy.filing" in universe.contents:
203             filing = universe.contents["mudpy.filing"]
204             if not prefix:
205                 prefix = filing.get("prefix")
206             if not search:
207                 search = filing.get("search")
208             if not stash:
209                 stash = filing.get("stash")
210
211         # if there's only one file loaded, try to work around a chicken<egg
212         elif hasattr(universe, "files") and len(
213             universe.files
214         ) == 1 and not universe.files[
215                 list(universe.files.keys())[0]].is_writeable():
216             data_file = universe.files[list(universe.files.keys())[0]].data
217
218             # try for a fallback default directory
219             if not stash:
220                 stash = data_file.get(".mudpy.filing.stash", "")
221
222             # try for a fallback root path
223             if not prefix:
224                 prefix = data_file.get(".mudpy.filing.prefix", "")
225
226             # try for a fallback search path
227             if not search:
228                 search = data_file.get(".mudpy.filing.search", "")
229
230         # another fallback root path, this time from the universe startdir
231         if hasattr(universe, "startdir"):
232             if not prefix:
233                 prefix = universe.startdir
234             elif not os.path.isabs(prefix):
235                 prefix = os.path.join(universe.startdir, prefix)
236
237     # when no root path is specified, assume the current working directory
238     if (not prefix or prefix == ".") and hasattr(universe, "startdir"):
239         prefix = universe.startdir
240
241     # make sure it's absolute
242     prefix = os.path.realpath(prefix)
243
244     # if there's no search path, just use the root path and etc
245     if not search:
246         search = [prefix, "etc"]
247
248     # work on a copy of the search path, to avoid modifying the caller's
249     else:
250         search = search[:]
251
252     # if there's no default path, use the last component of the search path
253     if not stash:
254         stash = search[-1]
255
256     # if an existing file or directory reference was supplied, prepend it
257     if relative:
258         if os.path.isdir(relative):
259             search = [relative] + search
260         else:
261             search = [os.path.dirname(relative)] + search
262
263     # make the search path entries absolute and throw away any dupes
264     clean_search = []
265     for each_path in search:
266         if not os.path.isabs(each_path):
267             each_path = os.path.realpath(os.path.join(prefix, each_path))
268         if each_path not in clean_search:
269             clean_search.append(each_path)
270
271     # start hunting for the file now
272     for each_path in clean_search:
273
274         # construct the candidate path
275         candidate = os.path.join(each_path, file_name)
276
277         # if the file exists and is readable, we're done
278         if os.path.isfile(candidate):
279             file_name = os.path.realpath(candidate)
280             break
281
282         # if the path is a directory, look for an __init__ file
283         if os.path.isdir(candidate):
284             file_name = os.path.realpath(
285                     os.path.join(candidate, "__init__.yaml"))
286             break
287
288     # it didn't exist after all, so use the default path instead
289     if not os.path.isabs(file_name):
290         file_name = os.path.join(stash, file_name)
291     if not os.path.isabs(file_name):
292         file_name = os.path.join(prefix, file_name)
293
294     # and normalize it last thing before returning
295     file_name = os.path.realpath(file_name)
296     return file_name