plexus-python-pulse-common 1.1.75__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,181 @@
1
+ import contextlib
2
+ import dataclasses
3
+ import re
4
+ from collections.abc import Generator, Iterator
5
+ from typing import Any
6
+
7
+ import docker
8
+ import docker.errors
9
+ import docker.models.containers
10
+ import docker.models.images
11
+ from plexus.common.utils import logger
12
+ from plexus.common.utils.jsonutils import JsonObject
13
+ from plexus.common.utils.strutils import parse_int_or, trim_to_empty
14
+
15
+ __all__ = [
16
+ "ImageName",
17
+ "docker_create_client",
18
+ "docker_build_image",
19
+ "docker_get_image",
20
+ "docker_pull_image",
21
+ "docker_fetch_image",
22
+ "docker_run_detached",
23
+ ]
24
+
25
+ docker_image_tag_regexp: re.Pattern[str] = re.compile(
26
+ r"^(?P<components>[a-z0-9]+((__?|-+)[a-z0-9]+)*(/[a-z0-9]+((__?|-+)[a-z0-9]+)*)*)(:(?P<tag>\w[\w.-]{0,127}))?$")
27
+
28
+ docker_image_tag_with_registry_regexp = re.compile(
29
+ r"^((?P<host>[a-zA-Z0-9.-]+)(:(?P<port>\d+))?/)?(?P<components>[a-z0-9]+((__?|-+)[a-z0-9]+)*(/[a-z0-9]+((__?|-+)[a-z0-9]+)*)*)(:(?P<tag>\w[\w.-]{0,127}))?$")
30
+
31
+
32
+ @dataclasses.dataclass
33
+ class ImageName(object):
34
+ registry_host: str | None
35
+ registry_port: int | None
36
+ components: list[str]
37
+ tag: str | None
38
+
39
+ @property
40
+ def registry(self) -> str:
41
+ if self.registry_host is None and self.registry_port is None:
42
+ return ""
43
+ if self.registry_port is None:
44
+ return self.registry_host
45
+ return f"{self.registry_host}:{self.registry_port}"
46
+
47
+ @property
48
+ def repository(self) -> str:
49
+ return "/".join(self.components)
50
+
51
+ @staticmethod
52
+ def parse(s: str):
53
+ match = docker_image_tag_regexp.match(s)
54
+ if match:
55
+ return ImageName(registry_host=None,
56
+ registry_port=None,
57
+ components=trim_to_empty(match.group("components")).split("/"),
58
+ tag=match.group("tag"))
59
+
60
+ match = docker_image_tag_with_registry_regexp.match(s)
61
+ if match:
62
+ return ImageName(registry_host=match.group("host"),
63
+ registry_port=parse_int_or(match.group("port"), None),
64
+ components=trim_to_empty(match.group("components")).split("/"),
65
+ tag=match.group("tag"))
66
+
67
+ return None
68
+
69
+
70
+ def docker_create_client(
71
+ registry: str,
72
+ username: str,
73
+ password: str,
74
+ ) -> contextlib.AbstractContextManager[docker.DockerClient]:
75
+ client = docker.DockerClient()
76
+ client.login(registry=registry, username=username, password=password, reauth=True)
77
+ return contextlib.closing(client)
78
+
79
+
80
+ def docker_build_image(
81
+ client: docker.DockerClient,
82
+ tag: str,
83
+ path: str,
84
+ dockerfile: str,
85
+ build_args: dict[str, str],
86
+ ) -> tuple[docker.models.images.Image, Iterator[dict[str, str]]]:
87
+ return client.images.build(tag=tag,
88
+ path=path,
89
+ dockerfile=dockerfile,
90
+ buildargs=build_args,
91
+ rm=True,
92
+ forcerm=True,
93
+ nocache=True)
94
+
95
+
96
+ def docker_get_image(
97
+ client: docker.DockerClient,
98
+ image: str,
99
+ ) -> docker.models.images.Image:
100
+ return client.images.get(image)
101
+
102
+
103
+ def docker_pull_image(
104
+ client: docker.DockerClient,
105
+ image: str,
106
+ fallback_local: bool = False,
107
+ ) -> docker.models.images.Image:
108
+ try:
109
+ return client.images.pull(image)
110
+ except docker.errors.ImageNotFound:
111
+ if not fallback_local:
112
+ raise
113
+ logger.warning("Image '%s' is not found from remote repository, try local repository instead", image)
114
+ except docker.errors.APIError:
115
+ if not fallback_local:
116
+ raise
117
+ logger.warning("Docker server returns an error while pulling image '%s', try local repository instead", image)
118
+
119
+ return docker_get_image(client, image)
120
+
121
+
122
+ def docker_fetch_image(
123
+ client: docker.DockerClient,
124
+ image: str,
125
+ force_pull: bool = False,
126
+ ) -> docker.models.images.Image:
127
+ if force_pull:
128
+ return docker_pull_image(client, image, fallback_local=True)
129
+ try:
130
+ return docker_get_image(client, image)
131
+ except Exception:
132
+ return docker_pull_image(client, image, fallback_local=False)
133
+
134
+
135
+ def docker_run_detached(
136
+ client: docker.DockerClient,
137
+ image: str,
138
+ name: str,
139
+ command: str | list[str],
140
+ volumes: dict[str, dict[str, str]],
141
+ environment: dict[str, str],
142
+ extra_hosts: dict[str, str],
143
+ timeout: int,
144
+ **kwargs,
145
+ ) -> tuple[JsonObject, Any]:
146
+ @contextlib.contextmanager
147
+ def managed_docker_run(
148
+ client: docker.DockerClient,
149
+ **kwargs,
150
+ ) -> Generator[docker.models.containers.Container, None, None]:
151
+ container_model = None
152
+ try:
153
+ container_model = client.containers.run(**kwargs)
154
+ yield container_model
155
+ finally:
156
+ if container_model is not None:
157
+ if container_model.status != "exited":
158
+ try:
159
+ container_model.stop()
160
+ except docker.errors.DockerException:
161
+ pass
162
+ container_model.wait()
163
+ try:
164
+ container_model.remove()
165
+ except docker.errors.DockerException:
166
+ pass
167
+
168
+ run_args = kwargs.copy()
169
+ run_args.update(dict(image=image,
170
+ name=name,
171
+ command=command,
172
+ volumes=volumes,
173
+ environment=environment,
174
+ extra_hosts=extra_hosts,
175
+ detach=True))
176
+
177
+ with managed_docker_run(client, **run_args) as container_model:
178
+ result = container_model.wait(timeout=timeout)
179
+ logs = container_model.logs()
180
+
181
+ return result, logs
@@ -0,0 +1,406 @@
1
+ from abc import ABC
2
+ from abc import abstractmethod
3
+ from typing import Annotated, Self
4
+
5
+ import numpy as np
6
+ import pydantic as pdt
7
+ import pyproj
8
+ import pyquaternion as pyquat
9
+ from plexus.common.utils.funcutils import singleton
10
+ from plexus.common.utils.strutils import parse_params_string, repr_data, str_conv
11
+
12
+
13
+ class Pose(object):
14
+ @classmethod
15
+ def from_numbers(
16
+ cls,
17
+ px: float,
18
+ py: float,
19
+ pz: float,
20
+ qx: float,
21
+ qy: float,
22
+ qz: float,
23
+ qw: float,
24
+ ts: float = 0,
25
+ ) -> Self:
26
+ """
27
+ Constructs a pose from numbers representing position and orientation
28
+ """
29
+ return Pose(ts, np.array([px, py, pz], dtype=np.float64), np.array([qw, qx, qy, qz], dtype=np.float64))
30
+
31
+ @classmethod
32
+ def add(cls, x: Self, d: Self) -> Self:
33
+ """
34
+ Performs pose SE3 addition, as x + d = y
35
+ """
36
+ xq = pyquat.Quaternion(x.q)
37
+ dq = pyquat.Quaternion(d.q)
38
+
39
+ yp = x.p + xq.rotate(d.p)
40
+ yq = xq * dq
41
+
42
+ return Pose(0, yp, yq.normalised.elements)
43
+
44
+ @classmethod
45
+ def sub(cls, y: Self, x: Self) -> Self:
46
+ """
47
+ Performs pose SE3 subtraction, as x + d = y => d = y - x
48
+ """
49
+ xq = pyquat.Quaternion(x.q)
50
+ yq = pyquat.Quaternion(y.q)
51
+
52
+ dp = xq.inverse.rotate(y.p - x.p)
53
+ dq = xq.inverse * yq
54
+
55
+ return Pose(0, dp, dq.normalised.elements)
56
+
57
+ @classmethod
58
+ def interpolate(cls, a: Self, b: Self, t: float) -> Self:
59
+ """
60
+ Interpolates between two given poses, as a * t + b * (1 - t)
61
+
62
+ :return: interpolated pose
63
+ """
64
+ ts = a.ts + (b.ts - a.ts) * t
65
+ p = a.p + (b.p - a.p) * t
66
+ q = pyquat.Quaternion.slerp(pyquat.Quaternion(a.q), pyquat.Quaternion(b.q), t)
67
+ return Pose(ts, p, q.normalised.elements)
68
+
69
+ def __init__(self, ts: float, p: np.ndarray, q: np.ndarray):
70
+ """
71
+ Represents a pose
72
+
73
+ :param ts: timestamp
74
+ :param p: position vector
75
+ :param q: orientation quaternion
76
+ """
77
+ self.ts = ts
78
+ self.p = p
79
+ self.q = q
80
+
81
+ def matrix(self) -> np.ndarray:
82
+ """
83
+ Returns the transformation matrix of this pose
84
+
85
+ :return: transformation matrix
86
+ """
87
+ r = pyquat.Quaternion(self.q).rotation_matrix
88
+ t = self.p[:, None]
89
+ return np.block([[r, t], [np.zeros((1, 3), dtype=np.float64), np.ones((1, 1), dtype=np.float64)]])
90
+
91
+ def translate(self, v: np.ndarray) -> np.ndarray:
92
+ """
93
+ Translate the given vector with the pose position
94
+
95
+ :param v: the vector to be translated
96
+
97
+ :return: translated vector
98
+ """
99
+ return self.p + v
100
+
101
+ def rotate(self, v: np.ndarray) -> np.ndarray:
102
+ """
103
+ Rotates the given vector with the pose orientation
104
+
105
+ :param v: the vector to be rotated
106
+
107
+ :return: rotated vector
108
+ """
109
+ return pyquat.Quaternion(self.q).rotate(v)
110
+
111
+ def __str__(self):
112
+ return repr_data(self)
113
+
114
+
115
+ class Proj(ABC):
116
+ @classmethod
117
+ @abstractmethod
118
+ def typename(cls) -> str:
119
+ pass
120
+
121
+ def __init__(self, spec):
122
+ self.spec = spec
123
+
124
+ def __str__(self):
125
+ return self.to_string()
126
+
127
+ def method(self) -> str:
128
+ return self.typename()
129
+
130
+ @abstractmethod
131
+ def ellipsoid(self) -> str:
132
+ pass
133
+
134
+ @abstractmethod
135
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
136
+ """
137
+ Performs forward projection from latitude/longitude to projection coordinate system
138
+
139
+ :param lat: latitude
140
+ :param lon: longitude
141
+
142
+ :return: tuple of x and y in projection coordinate system
143
+ """
144
+ pass
145
+
146
+ @abstractmethod
147
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
148
+ """
149
+ Performs inverse projection from projection coordinate system to latitude/longitude
150
+
151
+ :param x: x in projection coordinate system
152
+ :param y: y in projection coordinate system
153
+
154
+ :return: tuple of latitude and longitude
155
+ """
156
+ pass
157
+
158
+ @abstractmethod
159
+ def to_string(self) -> str:
160
+ pass
161
+
162
+
163
+ class EQDCProj(Proj):
164
+ """
165
+ Equidistant conic projection
166
+ Please refer to https://en.wikipedia.org/wiki/Equidistant_conic_projection
167
+ """
168
+
169
+ class Spec(pdt.BaseModel):
170
+ lat_1: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)]
171
+ lat_2: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)]
172
+ lon_0: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)] = 0.0
173
+
174
+ @classmethod
175
+ def typename(cls) -> str:
176
+ return "eqdc"
177
+
178
+ def __init__(self, **kwargs):
179
+ super(EQDCProj, self).__init__(EQDCProj.Spec(**kwargs))
180
+ self.proj = self.make_proj()
181
+
182
+ def ellipsoid(self) -> str:
183
+ return "WGS84"
184
+
185
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
186
+ x, y = self.proj(lon, lat)
187
+ return x, y
188
+
189
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
190
+ lon, lat = self.proj(x, y, inverse=True)
191
+ return lat, lon
192
+
193
+ def to_string(self) -> str:
194
+ return "{method}:lat_1={},lat_2={},lon_0={}".format(
195
+ self.spec.lat_1,
196
+ self.spec.lat_2,
197
+ self.spec.lon_0,
198
+ method=self.method(),
199
+ )
200
+
201
+ def make_proj(self):
202
+ return pyproj.Proj(
203
+ "+proj={method} +ellps={ellipsoid} +datum={ellipsoid} +units=m +no_defs +lat_1={} +lat_2={} +lon_0={}".format(
204
+ self.spec.lat_1,
205
+ self.spec.lat_2,
206
+ self.spec.lon_0,
207
+ method=self.method(),
208
+ ellipsoid=self.ellipsoid(),
209
+ )
210
+ )
211
+
212
+
213
+ class UTMProj(Proj):
214
+ """
215
+ Universal Transverse Mercator coordinate system
216
+ Please refer to https://en.wikipedia.org/wiki/Universal_Transverse_Mercator_coordinate_system
217
+ """
218
+
219
+ class Spec(pdt.BaseModel):
220
+ zone: Annotated[int, pdt.Strict()]
221
+ south: Annotated[bool, pdt.Strict()] = False
222
+
223
+ @classmethod
224
+ def typename(cls) -> str:
225
+ return "utm"
226
+
227
+ def __init__(self, **kwargs):
228
+ super(UTMProj, self).__init__(UTMProj.Spec(**kwargs))
229
+ self.proj = self.make_proj()
230
+
231
+ def ellipsoid(self) -> str:
232
+ return "WGS84"
233
+
234
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
235
+ x, y = self.proj(lon, lat)
236
+ return x, y
237
+
238
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
239
+ lon, lat = self.proj(x, y, inverse=True)
240
+ return lat, lon
241
+
242
+ def to_string(self) -> str:
243
+ return "{method}:zone={},south={}".format(self.spec.zone, self.spec.south, method=self.method())
244
+
245
+ def make_proj(self):
246
+ return pyproj.Proj(
247
+ "+proj={method} +ellps={ellipsoid} +datum={ellipsoid} +units=m +no_defs +zone={} {}".format(
248
+ self.spec.zone,
249
+ "+south" if self.spec.south else "",
250
+ method=self.method(),
251
+ ellipsoid=self.ellipsoid(),
252
+ )
253
+ )
254
+
255
+
256
+ class WebMercProj(Proj):
257
+ """
258
+ Web Mercator projection
259
+ Please refer to https://en.wikipedia.org/wiki/Web_Mercator_projection
260
+ """
261
+
262
+ class Spec(pdt.BaseModel):
263
+ lon_0: Annotated[float, pdt.Strict(), pdt.AllowInfNan(False)] = 0.0
264
+
265
+ @classmethod
266
+ def typename(cls) -> str:
267
+ return "webmerc"
268
+
269
+ def __init__(self, **kwargs):
270
+ super(WebMercProj, self).__init__(WebMercProj.Spec(**kwargs))
271
+ self.proj = self.make_proj()
272
+
273
+ def ellipsoid(self) -> str:
274
+ return "WGS84"
275
+
276
+ def from_latlon(self, lat: float, lon: float) -> tuple[float, float]:
277
+ x, y = self.proj(lon, lat)
278
+ return x, y
279
+
280
+ def to_latlon(self, x: float, y: float) -> tuple[float, float]:
281
+ lon, lat = self.proj(x, y, inverse=True)
282
+ return lat, lon
283
+
284
+ def to_string(self) -> str:
285
+ return "{method}:lon_0={}".format(self.spec.lon_0, method=self.method())
286
+
287
+ def make_proj(self):
288
+ return pyproj.Proj(
289
+ "+proj={method} +ellps={ellipsoid} +datum={ellipsoid} +units=m +no_defs +lon_0={}".format(
290
+ self.spec.lon_0,
291
+ method=self.method(),
292
+ ellipsoid=self.ellipsoid(),
293
+ ),
294
+ )
295
+
296
+
297
+ @singleton
298
+ def default_proj() -> Proj:
299
+ return make_proj("eqdc:lat_1=30.0,lat_2=50.0")
300
+
301
+
302
+ def make_proj(spec_str: str) -> Proj | None:
303
+ """
304
+ Constructs cartographic projection from the given name and arguments
305
+
306
+ :param spec_str: projection spec string
307
+
308
+ :return: projection
309
+ """
310
+
311
+ if spec_str is None:
312
+ return None
313
+
314
+ match spec_str.split(":", maxsplit=1):
315
+ case [name]:
316
+ kwargs = {}
317
+ case [name, args]:
318
+ kwargs = parse_params_string(args, str_parser=str_conv)
319
+ case _:
320
+ raise ValueError("bad spec string")
321
+
322
+ if name.lower() == UTMProj.typename():
323
+ return UTMProj(**kwargs)
324
+ if name.lower() == EQDCProj.typename():
325
+ return EQDCProj(**kwargs)
326
+ if name.lower() == WebMercProj.typename():
327
+ return WebMercProj(**kwargs)
328
+
329
+ return None
330
+
331
+
332
+ class Coord(object):
333
+ @classmethod
334
+ def from_pose(cls, pose: Pose, proj: Proj | None) -> Self:
335
+ """
336
+ Creates a coord from given pose
337
+
338
+ :param pose: pose based on which the coord is created
339
+ :param proj: cartographic projection
340
+
341
+ :return: generated coord
342
+ """
343
+ return cls.from_xy(pose.p[0], pose.p[1], pose.p[2], proj, pose.ts)
344
+
345
+ @classmethod
346
+ def from_latlon(cls, lat: float, lon: float, ele: float, proj: Proj | None, ts: float = 0) -> Self:
347
+ """
348
+ Creates a coord from latitude/longitude
349
+
350
+ :param lat: latitude
351
+ :param lon: longitude
352
+ :param ele: elevation
353
+ :param proj: cartographic projection
354
+ :param ts: timestamp
355
+
356
+ :return: generated coord
357
+ """
358
+ if proj is None:
359
+ return Coord(ts, lat, lon, ele, 0.0, 0.0, pdt.BaseModel())
360
+
361
+ x, y = proj.from_latlon(lat, lon)
362
+ return Coord(ts, lat, lon, ele, x, y, proj.spec)
363
+
364
+ @classmethod
365
+ def from_xy(cls, x: float, y: float, ele: float, proj: Proj | None, ts: float = 0) -> Self:
366
+ """
367
+ Creates a coord from coordinate in cartographic projection
368
+
369
+ :param x: x coordinate in cartographic projection
370
+ :param y: y coordinate in cartographic projection
371
+ :param ele: elevation
372
+ :param proj: cartographic projection
373
+ :param ts: timestamp
374
+
375
+ :return: a coord
376
+ """
377
+ if proj is None:
378
+ return Coord(ts, 0.0, 0.0, ele, x, y, pdt.BaseModel())
379
+
380
+ lat, lon = proj.to_latlon(x, y)
381
+ return Coord(ts, lat, lon, ele, x, y, proj.spec)
382
+
383
+ def __init__(self, ts: float, lat: float, lon: float, ele: float, x: float, y: float, spec: pdt.BaseModel):
384
+ """
385
+ Represents a coordinate
386
+
387
+ :param ts: timestamp
388
+ :param lat: latitude
389
+ :param lon: longitude
390
+ :param ele: elevation
391
+ :param x: x in projection coordinate system
392
+ :param y: y in projection coordinate system
393
+ :param spec: projection specification
394
+ """
395
+ self.ts = ts
396
+ self.lat = lat
397
+ self.lon = lon
398
+ self.x = x
399
+ self.y = y
400
+ self.spec = spec
401
+ self.ele = ele
402
+ self.xy = np.array([x, y], dtype=np.float64)
403
+ self.xyz = np.array([x, y, ele], dtype=np.float64)
404
+
405
+ def __str__(self):
406
+ return repr_data(self)
@@ -0,0 +1,96 @@
1
+ import datetime
2
+ import os
3
+ from collections.abc import Generator, Iterable
4
+ from typing import Any
5
+
6
+ import ujson as json
7
+ from plexus.common.utils.dtutils import dt_format, dt_parse, extended_format
8
+ from plexus.common.utils.iterutils import batched
9
+ from plexus.common.utils.jsonutils import JsonType, JsonValueCompatible
10
+ from plexus.common.utils.jsonutils import json_reformat
11
+
12
+ from plexus.pulse.common.utils.pathutils import collect_volumed_filenames, populate_volumed_filenames
13
+
14
+ __all__ = [
15
+ "json_datetime_decoder",
16
+ "json_datetime_encoder",
17
+ "json_loads",
18
+ "json_dumps",
19
+ "read_chunked_jsonl",
20
+ "write_chunked_jsonl",
21
+ ]
22
+
23
+
24
+ def json_datetime_decoder(v: Any) -> datetime.datetime | None:
25
+ if v is None:
26
+ return None
27
+ if isinstance(v, str):
28
+ return json_datetime_decoder(dt_parse(v, extended_format(with_us=True, with_tz=True)))
29
+ if isinstance(v, datetime.datetime):
30
+ return v.replace(tzinfo=datetime.timezone.utc)
31
+ raise ValueError("unexpected type of value for datetime decoder")
32
+
33
+
34
+ def json_datetime_encoder(v: Any) -> str | None:
35
+ if v is None:
36
+ return None
37
+ if isinstance(v, str):
38
+ return json_datetime_encoder(dt_parse(v, extended_format(with_us=True, with_tz=True)))
39
+ if isinstance(v, datetime.datetime):
40
+ return dt_format(v.replace(tzinfo=datetime.timezone.utc), extended_format(with_us=True, with_tz=True))
41
+ raise ValueError("unexpected type of value for datetime encoder")
42
+
43
+
44
+ def json_deserializer(obj):
45
+ def value_formatter(value: JsonValueCompatible) -> JsonType:
46
+ if not isinstance(value, str):
47
+ return value
48
+ try:
49
+ return dt_parse(value, extended_format(with_us=True, with_tz=True))
50
+ except Exception:
51
+ return value
52
+
53
+ return json_reformat(obj, value_formatter=value_formatter)
54
+
55
+
56
+ def json_serializer(obj):
57
+ def unregistered_formatter(unregistered: Any) -> JsonType:
58
+ if isinstance(unregistered, datetime.datetime):
59
+ return dt_format(unregistered, extended_format(with_us=True, with_tz=True))
60
+ return None
61
+
62
+ return json_reformat(obj, raise_if_unregistered=False, unregistered_formatter=unregistered_formatter)
63
+
64
+
65
+ def json_loads(s: str) -> JsonType:
66
+ return json_deserializer(json.loads(s))
67
+
68
+
69
+ def json_dumps(obj: JsonType) -> str:
70
+ return json.dumps(json_serializer(obj), ensure_ascii=False, escape_forward_slashes=False)
71
+
72
+
73
+ def read_chunked_jsonl(template: str) -> Generator[tuple[JsonType, str], None, None]:
74
+ for path, _ in collect_volumed_filenames(template):
75
+ with open(path, mode="r", encoding="utf-8") as fh:
76
+ for line in fh:
77
+ yield json_loads(line), path
78
+
79
+
80
+ def write_chunked_jsonl(records: Iterable[JsonType], template: str, chunk_size: int) -> list[tuple[str, int]]:
81
+ generator = populate_volumed_filenames(template)
82
+ entry = []
83
+ for batch_index, batch in enumerate(batched(records, chunk_size)):
84
+ path, _ = next(generator)
85
+ lines = 0
86
+ with open(path, mode="w") as fh:
87
+ for record in batch:
88
+ fh.write(json_dumps(record))
89
+ fh.write("\n")
90
+ lines += 1
91
+ entry.append((path, lines))
92
+ if len(entry) == 1:
93
+ path, lines = entry[0]
94
+ os.rename(path, template)
95
+ return [(template, lines)]
96
+ return entry