tinyflux 1.2.0__tar.gz → 1.3.0__tar.gz

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.
Files changed (31) hide show
  1. {tinyflux-1.2.0/tinyflux.egg-info → tinyflux-1.3.0}/PKG-INFO +11 -10
  2. {tinyflux-1.2.0 → tinyflux-1.3.0}/README.md +7 -7
  3. {tinyflux-1.2.0 → tinyflux-1.3.0}/setup.cfg +3 -2
  4. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_index.py +17 -0
  5. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_measurement.py +2 -2
  6. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_point.py +112 -5
  7. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_queries.py +78 -2
  8. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_storages.py +117 -4
  9. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_tinyflux.py +177 -5
  10. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/database.py +46 -7
  11. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/index.py +21 -10
  12. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/point.py +79 -9
  13. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/queries.py +59 -8
  14. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/storages.py +3 -1
  15. tinyflux-1.3.0/tinyflux/version.py +3 -0
  16. {tinyflux-1.2.0 → tinyflux-1.3.0/tinyflux.egg-info}/PKG-INFO +11 -10
  17. tinyflux-1.2.0/tinyflux/version.py +0 -3
  18. {tinyflux-1.2.0 → tinyflux-1.3.0}/LICENSE +0 -0
  19. {tinyflux-1.2.0 → tinyflux-1.3.0}/MANIFEST.in +0 -0
  20. {tinyflux-1.2.0 → tinyflux-1.3.0}/pyproject.toml +0 -0
  21. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/__init__.py +0 -0
  22. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/conftest.py +0 -0
  23. {tinyflux-1.2.0 → tinyflux-1.3.0}/tests/test_utils.py +0 -0
  24. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/__init__.py +0 -0
  25. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/decorators.py +0 -0
  26. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/measurement.py +0 -0
  27. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/py.typed +0 -0
  28. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux/utils.py +0 -0
  29. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux.egg-info/SOURCES.txt +0 -0
  30. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux.egg-info/dependency_links.txt +0 -0
  31. {tinyflux-1.2.0 → tinyflux-1.3.0}/tinyflux.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyflux
3
- Version: 1.2.0
3
+ Version: 1.3.0
4
4
  Summary: The Tiny Time-Series Database Optimized for Your Happiness
5
5
  Home-page: https://github.com/citrusvanilla/tinyflux
6
6
  Author: Justin Fung
@@ -17,15 +17,16 @@ Classifier: Topic :: Database
17
17
  Classifier: Topic :: Database :: Database Engines/Servers
18
18
  Classifier: Topic :: Utilities
19
19
  Classifier: Programming Language :: Python :: 3
20
- Classifier: Programming Language :: Python :: 3.9
21
20
  Classifier: Programming Language :: Python :: 3.10
22
21
  Classifier: Programming Language :: Python :: 3.11
23
22
  Classifier: Programming Language :: Python :: 3.12
24
23
  Classifier: Programming Language :: Python :: 3.13
24
+ Classifier: Programming Language :: Python :: 3.14
25
+ Classifier: Programming Language :: Python :: 3.15
25
26
  Classifier: Programming Language :: Python :: Implementation :: CPython
26
27
  Classifier: Operating System :: OS Independent
27
28
  Classifier: Typing :: Typed
28
- Requires-Python: >=3.9
29
+ Requires-Python: >=3.10
29
30
  Description-Content-Type: text/markdown
30
31
  License-File: LICENSE
31
32
  Dynamic: license-file
@@ -37,7 +38,7 @@ Dynamic: license-file
37
38
 
38
39
  TinyFlux is the tiny time series database optimized for your happiness 😎
39
40
 
40
- TinyFlux is the time series version of [TinyDB](https://tinydb.readthedocs.io/en/latest/index.html) that is written in Python and has no external dependencies. It consistently writes atomically to file at over 6,000 writes per second and supports greater than 100 queries per second for time-based range queries on datasets up to 100,000 points. It's a perfect companion for small analytics workflows and apps, as well as at-home IOT data stores. TinyFlux has 100% test coverage, over 150,000 downloads, and no open issues.
41
+ TinyFlux is the time series version of [TinyDB](https://tinydb.readthedocs.io/en/latest/index.html) that is written in Python and has no external dependencies. It consistently writes atomically to file at over 6,000 writes per second and supports greater than 100 queries per second for time-based range queries on datasets up to 100,000 points. It's a perfect companion for small analytics workflows and apps, as well as at-home IOT data stores. TinyFlux has 100% test coverage, over 200,000 downloads, and no open issues.
41
42
 
42
43
  [![Documentation](https://readthedocs.org/projects/tinyflux/badge/?version=latest)](https://tinyflux.readthedocs.io/en/latest/?badge=latest)
43
44
  [![Version](https://img.shields.io/pypi/v/tinyflux.svg)](https://pypi.org/project/tinyflux/)
@@ -59,6 +60,8 @@ Install from PyPI:
59
60
  pip install tinyflux
60
61
  ```
61
62
 
63
+ TinyFlux supports CPython 3.10 through 3.15.
64
+
62
65
  ## Quick Start
63
66
 
64
67
  ```python
@@ -238,13 +241,11 @@ TinyFlux automatic indexing provides:
238
241
 
239
242
  ## TinyFlux Across the Internet
240
243
 
241
- - **IoT Sensor Networks**: Collect and analyze device telemetry
242
- - **System Monitoring**: Track server metrics and application performance
243
- - **Financial Data**: Store and query stock prices, trading volumes
244
- - **Scientific Research**: Log experimental measurements and observations
245
- - **Web Analytics**: Time-stamped user interaction data
244
+ Articles, tutorials, and other instances of TinyFlux in the wild:
246
245
 
247
- *Perfect for prototyping, edge computing, embedded systems, and applications where simplicity matters.*
246
+ - ["Introducing TinyFlux: The Tiny Time Series Database for Python-based IoT & Analytics Applications"](https://citrusvanilla.medium.com/introducing-tinyflux-the-tiny-time-series-database-for-python-based-iot-analytics-applications-c3ef3c3bedf): A Medium.com article announcing the release of TinyFlux
247
+ - ["Storing Time Series Data in Python Using TinyFluxDB"](http://www.steves-internet-guide.com/storing-time-series-data-python-using-tinyflux-db/): A tutorial from [Steve's Internet Guide](http://www.steves-internet-guide.com/), a portal for learning MQTT and IoT development for Python
248
+ - ["KaiCode 2024 Shortlist"](https://www.kaicode.org/2024.html): TinyFlux came in 10th place out of 412 entrants in the 7th edition of this open-source festival, a festival dedicated to recognizing projects with high-quality open-source principles
248
249
 
249
250
  ## Contributing
250
251
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  TinyFlux is the tiny time series database optimized for your happiness 😎
7
7
 
8
- TinyFlux is the time series version of [TinyDB](https://tinydb.readthedocs.io/en/latest/index.html) that is written in Python and has no external dependencies. It consistently writes atomically to file at over 6,000 writes per second and supports greater than 100 queries per second for time-based range queries on datasets up to 100,000 points. It's a perfect companion for small analytics workflows and apps, as well as at-home IOT data stores. TinyFlux has 100% test coverage, over 150,000 downloads, and no open issues.
8
+ TinyFlux is the time series version of [TinyDB](https://tinydb.readthedocs.io/en/latest/index.html) that is written in Python and has no external dependencies. It consistently writes atomically to file at over 6,000 writes per second and supports greater than 100 queries per second for time-based range queries on datasets up to 100,000 points. It's a perfect companion for small analytics workflows and apps, as well as at-home IOT data stores. TinyFlux has 100% test coverage, over 200,000 downloads, and no open issues.
9
9
 
10
10
  [![Documentation](https://readthedocs.org/projects/tinyflux/badge/?version=latest)](https://tinyflux.readthedocs.io/en/latest/?badge=latest)
11
11
  [![Version](https://img.shields.io/pypi/v/tinyflux.svg)](https://pypi.org/project/tinyflux/)
@@ -27,6 +27,8 @@ Install from PyPI:
27
27
  pip install tinyflux
28
28
  ```
29
29
 
30
+ TinyFlux supports CPython 3.10 through 3.15.
31
+
30
32
  ## Quick Start
31
33
 
32
34
  ```python
@@ -206,13 +208,11 @@ TinyFlux automatic indexing provides:
206
208
 
207
209
  ## TinyFlux Across the Internet
208
210
 
209
- - **IoT Sensor Networks**: Collect and analyze device telemetry
210
- - **System Monitoring**: Track server metrics and application performance
211
- - **Financial Data**: Store and query stock prices, trading volumes
212
- - **Scientific Research**: Log experimental measurements and observations
213
- - **Web Analytics**: Time-stamped user interaction data
211
+ Articles, tutorials, and other instances of TinyFlux in the wild:
214
212
 
215
- *Perfect for prototyping, edge computing, embedded systems, and applications where simplicity matters.*
213
+ - ["Introducing TinyFlux: The Tiny Time Series Database for Python-based IoT & Analytics Applications"](https://citrusvanilla.medium.com/introducing-tinyflux-the-tiny-time-series-database-for-python-based-iot-analytics-applications-c3ef3c3bedf): A Medium.com article announcing the release of TinyFlux
214
+ - ["Storing Time Series Data in Python Using TinyFluxDB"](http://www.steves-internet-guide.com/storing-time-series-data-python-using-tinyflux-db/): A tutorial from [Steve's Internet Guide](http://www.steves-internet-guide.com/), a portal for learning MQTT and IoT development for Python
215
+ - ["KaiCode 2024 Shortlist"](https://www.kaicode.org/2024.html): TinyFlux came in 10th place out of 412 entrants in the 7th edition of this open-source festival, a festival dedicated to recognizing projects with high-quality open-source principles
216
216
 
217
217
  ## Contributing
218
218
 
@@ -30,18 +30,19 @@ classifiers =
30
30
  Topic :: Database :: Database Engines/Servers
31
31
  Topic :: Utilities
32
32
  Programming Language :: Python :: 3
33
- Programming Language :: Python :: 3.9
34
33
  Programming Language :: Python :: 3.10
35
34
  Programming Language :: Python :: 3.11
36
35
  Programming Language :: Python :: 3.12
37
36
  Programming Language :: Python :: 3.13
37
+ Programming Language :: Python :: 3.14
38
+ Programming Language :: Python :: 3.15
38
39
  Programming Language :: Python :: Implementation :: CPython
39
40
  Operating System :: OS Independent
40
41
  Typing :: Typed
41
42
 
42
43
  [options]
43
44
  packages = tinyflux
44
- python_requires = >=3.9
45
+ python_requires = >=3.10
45
46
 
46
47
  [options.package_data]
47
48
  tinyflux = py.typed
@@ -350,6 +350,23 @@ def test_search_field_query():
350
350
  assert rst.items == set({})
351
351
 
352
352
 
353
+ def test_search_field_query_with_map():
354
+ """Test Index search applies map functions to field values."""
355
+ index = Index()
356
+ index._insert_fields(0, {"temp": 70.0})
357
+
358
+ rst = index.search(FieldQuery().temp.map(lambda x: x * 2) == 140.0)
359
+ assert rst.items == {0}
360
+
361
+ rst = index.search(
362
+ FieldQuery().map(lambda fields: fields["temp"] * 2) == 140.0
363
+ )
364
+ assert rst.items == {0}
365
+
366
+ rst = index.search(FieldQuery().temp.map(lambda _: 1 / 0) == 1)
367
+ assert rst.items == set({})
368
+
369
+
353
370
  def test_search_compound_query_not():
354
371
  """Test search_query of Index on compound 'not' queries."""
355
372
  # Some timestamps.
@@ -918,9 +918,9 @@ def test_update():
918
918
  m1.update(TagQuery().noop(), tags={"a": 1})
919
919
 
920
920
  with pytest.raises(
921
- ValueError, match="Field set must contain only numeric values."
921
+ ValueError, match="Field set must contain only numeric, string, or bool"
922
922
  ):
923
- m1.update(TagQuery().noop(), fields={"a": "a"})
923
+ m1.update(TagQuery().noop(), fields={"a": ["a"]})
924
924
 
925
925
  # Valid index, no index results.
926
926
  db.reindex()
@@ -2,7 +2,12 @@
2
2
 
3
3
  from datetime import datetime, timezone, timedelta
4
4
  import pytest
5
- from tinyflux.point import Point, validate_tags, validate_fields
5
+ from tinyflux.point import (
6
+ Point,
7
+ _serialize_field_value,
8
+ validate_tags,
9
+ validate_fields,
10
+ )
6
11
 
7
12
 
8
13
  @pytest.fixture
@@ -100,8 +105,13 @@ def test_args_and_kwargs_invalid(args, kwargs, exception_type):
100
105
  ),
101
106
  (
102
107
  validate_fields,
103
- [3, {1: "A"}, {"a": "A"}],
104
- [{"a": 1}, {"temp": 70.5}, {"count": 10, "avg": 25.3}],
108
+ [3, {1: "A"}, {"a": ["A"]}, {"a": b"A"}],
109
+ [
110
+ {"a": 1},
111
+ {"temp": 70.5},
112
+ {"count": 10, "avg": 25.3},
113
+ {"s": "A", "b": True, "n": None},
114
+ ],
105
115
  ),
106
116
  ],
107
117
  )
@@ -199,8 +209,8 @@ def invalid_field_values(test_times):
199
209
  {test_time: "all good"},
200
210
  {tuple((1, 2)): "ok"},
201
211
  {"key": {"a": "b"}},
202
- {"a": True}, # Invalid for fields (should be numeric/None)
203
- # Note: {"a": 123} is valid for fields, so not included
212
+ {"a": ["b"]},
213
+ # Note: {"a": 123}, {"a": "b"}, and {"a": True} are valid for fields.
204
214
  ]
205
215
 
206
216
 
@@ -533,3 +543,100 @@ def test_compact_keys(point_data, prefix_attr, key_indices, expected_values):
533
543
  # Check that values match expected
534
544
  for key, expected_value in expected_values.items():
535
545
  assert getattr(new_p_compact, data_attr)[key] == expected_value
546
+
547
+
548
+ @pytest.mark.parametrize(
549
+ "value,serialized",
550
+ [
551
+ (True, "true"),
552
+ (False, "false"),
553
+ ("abc", '"abc"'),
554
+ ("", '""'),
555
+ ("true", '"true"'),
556
+ ("1.5", '"1.5"'),
557
+ ("_none", '"_none"'),
558
+ ("a,b", '"a,b"'),
559
+ ('say "hi"', '"say \\"hi\\""'),
560
+ ("back\\slash", '"back\\\\slash"'),
561
+ ("line1\nline2\r\n", '"line1\\nline2\\r\\n"'),
562
+ ("\\n", '"\\\\n"'),
563
+ ("\u00e9\u4e2d", '"\u00e9\u4e2d"'),
564
+ (1, "1.0"),
565
+ (-2.5, "-2.5"),
566
+ (None, "_none"),
567
+ ],
568
+ )
569
+ def test_serialize_string_and_bool_fields(value, serialized):
570
+ """Test the serialized form of, and round trip for, each field type."""
571
+ p = Point(
572
+ time=datetime(2022, 1, 1, tzinfo=timezone.utc), fields={"k": value}
573
+ )
574
+ row = p._serialize_to_list()
575
+
576
+ assert row[-2:] == ("_field_k", serialized)
577
+ assert not any("\n" in i or "\r" in i for i in row)
578
+
579
+ result = Point()._deserialize_from_list(row)
580
+
581
+ # Numbers always come back as floats, everything else as it went in.
582
+ expected = float(value) if type(value) is int else value
583
+ assert result.fields == {"k": expected}
584
+ assert type(result.fields["k"]) is type(expected)
585
+
586
+
587
+ def test_serialize_numeric_field_subclasses():
588
+ """Test numeric subclasses keep the same serialized form as numbers."""
589
+
590
+ class MyInt(int):
591
+ pass
592
+
593
+ class MyFloat(float):
594
+ pass
595
+
596
+ p = Point(
597
+ time=datetime(2022, 1, 1, tzinfo=timezone.utc),
598
+ fields={"i": MyInt(1), "f": MyFloat(2.5)},
599
+ )
600
+ row = p._serialize_to_list()
601
+
602
+ assert row[-4:] == ("_field_i", "1.0", "_field_f", "2.5")
603
+
604
+
605
+ def test_serialize_unsupported_field_value():
606
+ """Test the serializer rejects unsupported values defensively."""
607
+ with pytest.raises(TypeError, match="Unsupported field value type: list"):
608
+ _serialize_field_value([], "_none")
609
+
610
+
611
+ def test_deserialize_legacy_and_unknown_field_values():
612
+ """Test that values written by earlier releases read back unchanged."""
613
+ row = [
614
+ "2022-01-01T00:00:00",
615
+ "m",
616
+ "_tag_t",
617
+ "true",
618
+ "_field_a",
619
+ "1.0",
620
+ "_field_b",
621
+ "-3",
622
+ "_field_c",
623
+ "2.5e-05",
624
+ "_field_d",
625
+ "_none",
626
+ "_field_e",
627
+ "nan",
628
+ "_field_f",
629
+ "garbage",
630
+ "_field_g",
631
+ '"unterminated',
632
+ ]
633
+ p = Point()._deserialize_from_list(row)
634
+
635
+ assert p.tags == {"t": "true"}
636
+ assert p.fields["a"] == 1.0 and isinstance(p.fields["a"], float)
637
+ assert p.fields["b"] == -3 and isinstance(p.fields["b"], int)
638
+ assert p.fields["c"] == 2.5e-05
639
+ assert p.fields["d"] is None
640
+ assert p.fields["e"] != p.fields["e"]
641
+ assert p.fields["f"] is None
642
+ assert p.fields["g"] is None
@@ -625,6 +625,27 @@ def test_hash():
625
625
  assert not (~(q1 & q5)).is_hashable()
626
626
 
627
627
 
628
+ def test_bool_numeric_field_query_hash_identity():
629
+ """Test bool field queries are distinct from numeric field queries."""
630
+ q_true = FieldQuery().a == True # noqa: E712
631
+ q_one = FieldQuery().a == 1
632
+ q_one_float = FieldQuery().a == 1.0
633
+ q_false = FieldQuery().a == False # noqa: E712
634
+ q_zero = FieldQuery().a == 0
635
+ q_zero_float = FieldQuery().a == 0.0
636
+
637
+ assert q_true != q_one
638
+ assert q_false != q_zero
639
+ assert q_one == q_one_float
640
+ assert q_zero == q_zero_float
641
+
642
+ assert q_true._hash[-1] != True # noqa: E712
643
+ assert len({q_true, q_one, q_one_float, q_false, q_zero, q_zero_float}) == 4
644
+
645
+ assert (q_true & q_one) != (q_one & q_one)
646
+ assert (q_false | q_zero) != (q_zero | q_zero)
647
+
648
+
628
649
  def test_empty_query_evaluation():
629
650
  """Test calling an empty query raises Exception."""
630
651
  with pytest.raises(TypeError):
@@ -870,6 +891,61 @@ def test_bad_queries():
870
891
  with pytest.raises(TypeError):
871
892
  TagQuery().city == ["a", "b"]
872
893
 
873
- # Test a non-numeric right hand side.
894
+ # Test a right hand side that is not numeric, string, or bool.
874
895
  with pytest.raises(TypeError):
875
- FieldQuery().a == "a"
896
+ FieldQuery().a == ["a"]
897
+
898
+
899
+ def test_string_and_bool_field_queries():
900
+ """Test queries against string and bool field values."""
901
+ s = Point(fields={"a": "abc"})
902
+ t = Point(fields={"a": True})
903
+ f = Point(fields={"a": False})
904
+ n = Point(fields={"a": 1})
905
+ z = Point(fields={"a": 0.0})
906
+ none = Point(fields={"a": None})
907
+ missing = Point(fields={"b": "abc"})
908
+ points = [s, t, f, n, z, none, missing]
909
+
910
+ yes, no = True, False
911
+
912
+ def matches(q):
913
+ return [p for p in points if q(p)]
914
+
915
+ # Equality is typed.
916
+ assert matches(FieldQuery().a == "abc") == [s]
917
+ assert matches(FieldQuery().a == "ABC") == []
918
+ assert matches(FieldQuery().a == "") == []
919
+ assert matches(FieldQuery().a == yes) == [t]
920
+ assert matches(FieldQuery().a == no) == [f]
921
+ assert matches(FieldQuery().a == 1) == [n]
922
+ assert matches(FieldQuery().a == 0) == [z]
923
+ assert matches(FieldQuery().a == "1") == []
924
+
925
+ # Inequality is typed too: a missing field is never a match.
926
+ assert matches(FieldQuery().a != "abc") == [t, f, n, z, none]
927
+ assert matches(FieldQuery().a != yes) == [s, f, n, z, none]
928
+ assert matches(FieldQuery().a != 1) == [s, t, f, z, none]
929
+
930
+ # Numeric comparisons skip strings and bools instead of raising.
931
+ assert matches(FieldQuery().a > 0) == [n]
932
+ assert matches(FieldQuery().a >= 0) == [n, z]
933
+ assert matches(FieldQuery().a < 1) == [z]
934
+ assert matches(FieldQuery().a <= 1) == [n, z]
935
+
936
+ # String comparisons are lexicographic and skip numbers and bools.
937
+ assert matches(FieldQuery().a > "abb") == [s]
938
+ assert matches(FieldQuery().a < "abd") == [s]
939
+ assert matches(FieldQuery().a >= "abc") == [s]
940
+ assert matches(FieldQuery().a <= "abc") == [s]
941
+
942
+ # Bools only order against bools.
943
+ assert matches(FieldQuery().a > no) == [t]
944
+ assert matches(FieldQuery().a <= yes) == [t, f]
945
+
946
+ # Compound queries work as for any other field value.
947
+ assert matches((FieldQuery().a == "abc") | (FieldQuery().a == yes)) == [
948
+ s,
949
+ t,
950
+ ]
951
+ assert matches(~(FieldQuery().a == "abc")) == [t, f, n, z, none, missing]
@@ -352,7 +352,7 @@ def test_connect_to_existing_csv(tmpdir, csv_storage_with_counters):
352
352
 
353
353
  # Mock CSV store. Insert points out of order.
354
354
  path = os.path.join(tmpdir, "test.csv")
355
- with open(path, "w") as f:
355
+ with open(path, "w", newline="") as f:
356
356
  w = csv.writer(f)
357
357
  w.writerow(p2._serialize_to_list())
358
358
  w.writerow(p1._serialize_to_list())
@@ -478,7 +478,7 @@ def test_multiple_appends(
478
478
 
479
479
  # Mock CSV store. Insert points out of order.
480
480
  path = os.path.join(tmpdir, "test.csv")
481
- with open(path, "w") as f:
481
+ with open(path, "w", newline="") as f:
482
482
  w = csv.writer(f)
483
483
  w.writerow(p2._serialize_to_list())
484
484
  w.writerow(p1._serialize_to_list())
@@ -514,7 +514,7 @@ def test_multiple_reads(
514
514
  path = os.path.join(tmpdir, "test.csv")
515
515
  t = datetime.now(timezone.utc)
516
516
 
517
- with open(path, "w") as f:
517
+ with open(path, "w", newline="") as f:
518
518
  w = csv.writer(f)
519
519
  for _ in range(10):
520
520
  w.writerow(Point(time=t)._serialize_to_list())
@@ -533,7 +533,7 @@ def test_multiple_reads(
533
533
 
534
534
  # Mock CSV store. Insert points out-of-order.
535
535
  path = os.path.join(tmpdir, "test.csv")
536
- with open(path, "w") as f:
536
+ with open(path, "w", newline="") as f:
537
537
  w = csv.writer(f)
538
538
  for _ in range(10):
539
539
  w.writerow(Point(time=t)._serialize_to_list())
@@ -667,3 +667,116 @@ def test_compact_key_prefixes(tmpdir):
667
667
  assert m._serialize_point(
668
668
  p, compact_key_prefixes=False
669
669
  ) == p._serialize_to_list(compact_key_prefixes=False)
670
+
671
+
672
+ STRING_AND_BOOL_FIELDS = {
673
+ "s": "plain",
674
+ "empty": "",
675
+ "t": True,
676
+ "f": False,
677
+ "str_true": "true",
678
+ "str_num": "1.5",
679
+ "str_none": "_none",
680
+ "comma": "a,b",
681
+ "quote": 'she said "hi", then left',
682
+ "backslash": "C:\\temp\\new",
683
+ "newline": "line1\nline2\r\nline3\r",
684
+ "unicode": "caf\u00e9 \u4e2d\u6587",
685
+ "n": 1.5,
686
+ "i": 3,
687
+ "none": None,
688
+ }
689
+
690
+
691
+ @pytest.mark.parametrize("compact_key_prefixes", [False, True])
692
+ def test_string_and_bool_fields_round_trip(tmpdir, compact_key_prefixes):
693
+ """Test writing, closing, reopening, and reading string/bool fields."""
694
+ path = os.path.join(tmpdir, "test.csv")
695
+ t = datetime(2022, 1, 1, tzinfo=timezone.utc)
696
+ points = [
697
+ Point(time=t, measurement="m", fields=STRING_AND_BOOL_FIELDS),
698
+ Point(
699
+ time=t + timedelta(seconds=1),
700
+ measurement="m",
701
+ tags={"tag": "true"},
702
+ fields={"s": "second", "t": False},
703
+ ),
704
+ ]
705
+
706
+ db = TinyFlux(path, encoding="utf-8")
707
+ db.insert_multiple(points, compact_key_prefixes=compact_key_prefixes)
708
+ db.close()
709
+
710
+ # Each point is still exactly one line in the file.
711
+ with open(path, encoding="utf-8", newline="") as f:
712
+ assert len(f.read().split("\n")) == len(points) + 1
713
+
714
+ db = TinyFlux(path, encoding="utf-8")
715
+ assert len(db) == 2
716
+ result = db.all()
717
+ assert result == points
718
+
719
+ # Types survive, in particular bool vs. int vs. str.
720
+ first = result[0].fields
721
+ for key, value in STRING_AND_BOOL_FIELDS.items():
722
+ assert first[key] == value
723
+ assert type(first[key]) is (float if key == "i" else type(value))
724
+
725
+ # The index built on reopen sees the typed values.
726
+ assert db.get_field_values("s") == ["plain", "second"]
727
+ assert db.get_field_values("t") == [True, False]
728
+ db.close()
729
+
730
+
731
+ def test_string_and_bool_fields_memory_storage():
732
+ """Test string and bool fields in memory storage."""
733
+ db = TinyFlux(storage=MemoryStorage)
734
+ p = Point(fields=STRING_AND_BOOL_FIELDS)
735
+ db.insert(p)
736
+
737
+ assert db.all() == [p]
738
+ assert db.get_field_values("quote") == [STRING_AND_BOOL_FIELDS["quote"]]
739
+
740
+
741
+ def test_numeric_only_files_from_earlier_releases_read_identically(tmpdir):
742
+ """Test that a file written before string/bool support reads unchanged."""
743
+ path = os.path.join(tmpdir, "legacy.csv")
744
+
745
+ with open(path, "w", newline="") as f:
746
+ f.write(
747
+ "2022-01-01T00:00:00,m,_tag_a,b,_field_x,1.0,_field_y,-2.5,"
748
+ "_field_z,_none\r\n"
749
+ "2022-01-01T00:00:01,m,t_a,c,f_x,3.0,f_y,1e-07\r\n"
750
+ )
751
+
752
+ db = TinyFlux(path)
753
+ assert db.all() == [
754
+ Point(
755
+ time=datetime(2022, 1, 1, tzinfo=timezone.utc),
756
+ measurement="m",
757
+ tags={"a": "b"},
758
+ fields={"x": 1.0, "y": -2.5, "z": None},
759
+ ),
760
+ Point(
761
+ time=datetime(2022, 1, 1, 0, 0, 1, tzinfo=timezone.utc),
762
+ measurement="m",
763
+ tags={"a": "c"},
764
+ fields={"x": 3.0, "y": 1e-07},
765
+ ),
766
+ ]
767
+
768
+ # New string/bool points can be appended to an old file.
769
+ db.insert(
770
+ Point(
771
+ time=datetime(2022, 1, 1, 0, 0, 2, tzinfo=timezone.utc),
772
+ measurement="m",
773
+ fields={"x": "on", "y": True},
774
+ )
775
+ )
776
+ db.close()
777
+
778
+ db = TinyFlux(path)
779
+ assert len(db) == 3
780
+ assert db.all()[0].fields == {"x": 1.0, "y": -2.5, "z": None}
781
+ assert db.all()[2].fields == {"x": "on", "y": True}
782
+ db.close()