tinyflux 1.2.1__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.1/tinyflux.egg-info → tinyflux-1.3.0}/PKG-INFO +1 -1
  2. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_measurement.py +2 -2
  3. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_point.py +112 -5
  4. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_queries.py +78 -2
  5. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_storages.py +113 -0
  6. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_tinyflux.py +159 -2
  7. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/database.py +46 -7
  8. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/index.py +1 -1
  9. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/point.py +79 -9
  10. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/queries.py +54 -8
  11. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/storages.py +3 -1
  12. tinyflux-1.3.0/tinyflux/version.py +3 -0
  13. {tinyflux-1.2.1 → tinyflux-1.3.0/tinyflux.egg-info}/PKG-INFO +1 -1
  14. tinyflux-1.2.1/tinyflux/version.py +0 -3
  15. {tinyflux-1.2.1 → tinyflux-1.3.0}/LICENSE +0 -0
  16. {tinyflux-1.2.1 → tinyflux-1.3.0}/MANIFEST.in +0 -0
  17. {tinyflux-1.2.1 → tinyflux-1.3.0}/README.md +0 -0
  18. {tinyflux-1.2.1 → tinyflux-1.3.0}/pyproject.toml +0 -0
  19. {tinyflux-1.2.1 → tinyflux-1.3.0}/setup.cfg +0 -0
  20. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/__init__.py +0 -0
  21. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/conftest.py +0 -0
  22. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_index.py +0 -0
  23. {tinyflux-1.2.1 → tinyflux-1.3.0}/tests/test_utils.py +0 -0
  24. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/__init__.py +0 -0
  25. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/decorators.py +0 -0
  26. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/measurement.py +0 -0
  27. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/py.typed +0 -0
  28. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux/utils.py +0 -0
  29. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux.egg-info/SOURCES.txt +0 -0
  30. {tinyflux-1.2.1 → tinyflux-1.3.0}/tinyflux.egg-info/dependency_links.txt +0 -0
  31. {tinyflux-1.2.1 → 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.1
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
@@ -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]
@@ -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()
@@ -857,6 +857,95 @@ def test_search_field_map_index_storage_parity():
857
857
  assert db_index.search(query) == [point]
858
858
 
859
859
 
860
+ def test_negated_field_query_index_storage_read_parity():
861
+ """Test negated FieldQuery reads match with and without the index."""
862
+ t = datetime(2022, 1, 1, tzinfo=timezone.utc)
863
+ points = [
864
+ Point(time=t, measurement="m", fields={"a": 1}),
865
+ Point(time=t + timedelta(seconds=1), measurement="m", fields={"a": 2}),
866
+ Point(time=t + timedelta(seconds=2), measurement="m", fields={"b": 3}),
867
+ Point(time=t + timedelta(seconds=3), measurement="n", fields={"a": 1}),
868
+ Point(time=t + timedelta(seconds=4), measurement="n", fields={"b": 4}),
869
+ Point(
870
+ time=t + timedelta(seconds=5),
871
+ measurement="full",
872
+ fields={"a": 9},
873
+ ),
874
+ ]
875
+
876
+ db_storage = TinyFlux(storage=MemoryStorage, auto_index=False)
877
+ db_storage.insert_multiple(points)
878
+
879
+ db_index = TinyFlux(storage=MemoryStorage, auto_index=True)
880
+ db_index.insert_multiple(points)
881
+
882
+ queries = [
883
+ ~FieldQuery().a.exists(),
884
+ ~(FieldQuery().a == 1),
885
+ (MeasurementQuery() == "m") & ~(FieldQuery().a == 1),
886
+ ]
887
+
888
+ for query in queries:
889
+ for measurement in (None, "m", "full"):
890
+ assert db_index.search(query, measurement=measurement) == (
891
+ db_storage.search(query, measurement=measurement)
892
+ )
893
+ assert db_index.count(query, measurement=measurement) == (
894
+ db_storage.count(query, measurement=measurement)
895
+ )
896
+ assert db_index.contains(query, measurement=measurement) == (
897
+ db_storage.contains(query, measurement=measurement)
898
+ )
899
+ assert db_index.get(query, measurement=measurement) == (
900
+ db_storage.get(query, measurement=measurement)
901
+ )
902
+ assert db_index.select(
903
+ "fields.a", query, measurement=measurement
904
+ ) == (db_storage.select("fields.a", query, measurement=measurement))
905
+
906
+
907
+ def test_negated_field_query_index_storage_write_parity():
908
+ """Test negated FieldQuery writes match with and without the index."""
909
+ t = datetime(2022, 1, 1, tzinfo=timezone.utc)
910
+ query = (MeasurementQuery() == "m") & ~FieldQuery().a.exists()
911
+
912
+ def make_points():
913
+ return [
914
+ Point(time=t, measurement="m", fields={"a": 1}),
915
+ Point(
916
+ time=t + timedelta(seconds=1),
917
+ measurement="m",
918
+ fields={"a": 2},
919
+ ),
920
+ Point(
921
+ time=t + timedelta(seconds=2),
922
+ measurement="m",
923
+ fields={"b": 3},
924
+ ),
925
+ Point(
926
+ time=t + timedelta(seconds=3),
927
+ measurement="n",
928
+ fields={"b": 4},
929
+ ),
930
+ ]
931
+
932
+ db_storage = TinyFlux(storage=MemoryStorage, auto_index=False)
933
+ db_storage.insert_multiple(make_points())
934
+
935
+ db_index = TinyFlux(storage=MemoryStorage, auto_index=True)
936
+ db_index.insert_multiple(make_points())
937
+
938
+ assert db_index.update(query, fields={"updated": True}) == 1
939
+ assert db_storage.update(query, fields={"updated": True}) == 1
940
+ assert db_index.all() == db_storage.all()
941
+
942
+ assert db_index.remove(query) == 1
943
+ assert db_storage.remove(query) == 1
944
+ assert db_index.remove(FieldQuery().updated == True) == 0 # noqa: E712
945
+ assert db_storage.remove(FieldQuery().updated == True) == 0 # noqa: E712
946
+ assert db_index.all() == db_storage.all()
947
+
948
+
860
949
  def test_select():
861
950
  """Test select method."""
862
951
  db = TinyFlux(storage=MemoryStorage, auto_index=False)
@@ -960,9 +1049,9 @@ def test_update():
960
1049
  db.update(TagQuery().noop(), tags={"a": 1})
961
1050
 
962
1051
  with pytest.raises(
963
- ValueError, match="Field set must contain only numeric values."
1052
+ ValueError, match="Field set must contain only numeric, string, or bool"
964
1053
  ):
965
- db.update(TagQuery().noop(), fields={"a": "a"})
1054
+ db.update(TagQuery().noop(), fields={"a": ["a"]})
966
1055
 
967
1056
  # Missing updates.
968
1057
  with pytest.raises(ValueError):
@@ -1639,3 +1728,71 @@ def test_all_get_methods_parity_storage_vs_index():
1639
1728
  assert sorted(
1640
1729
  db_test.get_tag_values(["device"], "measurement2")["device"]
1641
1730
  ) == ["C", "D"]
1731
+
1732
+
1733
+ def test_string_and_bool_fields_mixed_types(tmpdir):
1734
+ """Test search and update with mixed field types under one field key."""
1735
+ path = os.path.join(tmpdir, "test.csv")
1736
+ t = datetime(2022, 1, 1, tzinfo=timezone.utc)
1737
+ values = ["on", True, 1, 2.5, "true", False, None, "1"]
1738
+ points = [
1739
+ Point(time=t + timedelta(seconds=i), fields={"state": v})
1740
+ for i, v in enumerate(values)
1741
+ ]
1742
+
1743
+ db = TinyFlux(path)
1744
+ db.insert_multiple(points)
1745
+
1746
+ def check(db):
1747
+ q = FieldQuery().state
1748
+ assert db.search(q == "on") == [points[0]]
1749
+ assert db.search(q == "true") == [points[4]]
1750
+ assert db.search(q == True) == [points[1]] # noqa: E712
1751
+ assert db.search(q == False) == [points[5]] # noqa: E712
1752
+ assert db.search(q == 1) == [points[2]]
1753
+ assert db.search(q == "1") == [points[7]]
1754
+ assert db.search(q >= 1) == [points[2], points[3]]
1755
+ assert db.search(q > "a") == [points[0], points[4]]
1756
+ assert db.search(q != "on") == [
1757
+ points[i] for i in (1, 2, 3, 4, 5, 6, 7)
1758
+ ]
1759
+ assert db.count(q == "on") == 1
1760
+ assert db.get_field_values("state") == values
1761
+
1762
+ # Index is valid.
1763
+ check(db)
1764
+
1765
+ # No index.
1766
+ db._index.invalidate()
1767
+ check(db)
1768
+
1769
+ # Reopened from disk.
1770
+ db.close()
1771
+ db = TinyFlux(path)
1772
+ check(db)
1773
+ db.reindex()
1774
+ check(db)
1775
+
1776
+ # Update from one type to another.
1777
+ assert db.update(FieldQuery().state == "on", fields={"state": False}) == 1
1778
+ assert db.search(FieldQuery().state == "on") == []
1779
+ assert db.get_field_values("state")[0] is False
1780
+ db.close()
1781
+
1782
+ db = TinyFlux(path)
1783
+ assert db.get_field_values("state")[0] is False
1784
+ db.close()
1785
+
1786
+
1787
+ def test_string_and_bool_fields_in_measurement_and_select():
1788
+ """Test string/bool fields through the Measurement and select APIs."""
1789
+ db = TinyFlux(storage=MemoryStorage)
1790
+ m = db.measurement("m")
1791
+ m.insert(Point(fields={"s": "x", "b": True}))
1792
+ m.insert(Point(fields={"s": "y", "b": False}))
1793
+
1794
+ assert m.get_field_values("s") == ["x", "y"]
1795
+ assert m.get_field_values("b") == [True, False]
1796
+ assert m.count(FieldQuery().b == True) == 1 # noqa: E712
1797
+ assert m.select("fields.s", FieldQuery().s == "y") == ["y"]
1798
+ assert db.select("fields.b", FieldQuery().s >= "x") == [True, False]
@@ -2,6 +2,7 @@
2
2
 
3
3
  import copy
4
4
  from datetime import datetime, timezone
5
+ import operator
5
6
  from typing import (
6
7
  Any,
7
8
  Callable,
@@ -36,6 +37,24 @@ from .queries import (
36
37
  from .storages import CSVStorage, Storage
37
38
 
38
39
 
40
+ def _query_contains_negated_field_query(query: Query) -> bool:
41
+ """Return whether a query needs storage evaluation for field negation."""
42
+ if not isinstance(query, CompoundQuery):
43
+ return False
44
+
45
+ if (
46
+ query.operator == operator.not_
47
+ and isinstance(query.query1, SimpleQuery)
48
+ and query.query1.point_attr == "_fields"
49
+ ):
50
+ return True
51
+
52
+ return _query_contains_negated_field_query(query.query1) or (
53
+ query.query2 is not None
54
+ and _query_contains_negated_field_query(query.query2)
55
+ )
56
+
57
+
39
58
  class TinyFlux:
40
59
  """The TinyFlux class containing the interface for the TinyFlux package.
41
60
 
@@ -220,8 +239,12 @@ class TinyFlux:
220
239
  Returns:
221
240
  True if point found, else False.
222
241
  """
242
+ use_index = (
243
+ self._index.valid and not _query_contains_negated_field_query(query)
244
+ )
245
+
223
246
  # If the index is valid, check it.
224
- if self._index.valid:
247
+ if use_index:
225
248
  if measurement:
226
249
  mq = MeasurementQuery() == measurement
227
250
  index_rst = self._index.search(mq & query)
@@ -261,8 +284,12 @@ class TinyFlux:
261
284
  Returns:
262
285
  A count of matching points in the measurement.
263
286
  """
287
+ use_index = (
288
+ self._index.valid and not _query_contains_negated_field_query(query)
289
+ )
290
+
264
291
  # If the index is valid, check it.
265
- if self._index.valid:
292
+ if use_index:
266
293
  if measurement:
267
294
  mq = MeasurementQuery() == measurement
268
295
  index_rst = self._index.search(mq & query)
@@ -327,7 +354,9 @@ class TinyFlux:
327
354
  Returns:
328
355
  First found Point or None.
329
356
  """
330
- use_index = self._index.valid
357
+ use_index = (
358
+ self._index.valid and not _query_contains_negated_field_query(query)
359
+ )
331
360
 
332
361
  # If we are auto-indexing and the index is valid, check it.
333
362
  if use_index:
@@ -757,7 +786,9 @@ class TinyFlux:
757
786
  if not isinstance(query, (SimpleQuery, CompoundQuery)):
758
787
  raise ValueError("query must be a TinyFlux Query.")
759
788
 
760
- use_index = self._index.valid
789
+ use_index = (
790
+ self._index.valid and not _query_contains_negated_field_query(query)
791
+ )
761
792
 
762
793
  # If we are auto-indexing and the index is valid, check it.
763
794
  if use_index:
@@ -870,7 +901,9 @@ class TinyFlux:
870
901
  else:
871
902
  raise ValueError(f"Invalid key `{key}`.")
872
903
 
873
- use_index = self._index.valid
904
+ use_index = (
905
+ self._index.valid and not _query_contains_negated_field_query(query)
906
+ )
874
907
 
875
908
  results = []
876
909
 
@@ -1337,7 +1370,9 @@ class TinyFlux:
1337
1370
  Raises:
1338
1371
  OSError if storage cannot be written to.
1339
1372
  """
1340
- use_index = self._index.valid
1373
+ use_index = (
1374
+ self._index.valid and not _query_contains_negated_field_query(query)
1375
+ )
1341
1376
 
1342
1377
  # If we are auto-indexing and the index is valid, check it.
1343
1378
  if use_index:
@@ -1492,7 +1527,11 @@ class TinyFlux:
1492
1527
  unset_tags=unset_tags,
1493
1528
  )
1494
1529
 
1495
- use_index = not update_all and self._index.valid
1530
+ use_index = (
1531
+ not update_all
1532
+ and self._index.valid
1533
+ and not _query_contains_negated_field_query(query)
1534
+ )
1496
1535
 
1497
1536
  # If we are auto-indexing and the index is valid, check it.
1498
1537
  if use_index:
@@ -110,7 +110,7 @@ class Index:
110
110
 
111
111
  _num_items: int
112
112
  _tags: Dict[str, Dict[Union[None, str], List[int]]]
113
- _fields: Dict[str, List[Tuple[int, Optional[float]]]]
113
+ _fields: Dict[str, List[Tuple[int, FieldValue]]]
114
114
  _measurements: Dict[str, List[int]]
115
115
  _timestamps: List[float]
116
116
  _valid: bool
@@ -6,8 +6,8 @@ deserialized from Storage. SimpleQuery act upon individual Points.
6
6
 
7
7
  A Point is comprised of a timestamp, a measurement, fields, and tags.
8
8
 
9
- Fields contains string/numeric key-values, while tags contain
10
- string/string key-values. This is enforced upon Point instantiation.
9
+ Fields contains string/(numeric, string, or bool) key-values, while tags
10
+ contain string/string key-values. This is enforced upon Point instantiation.
11
11
 
12
12
  Usage:
13
13
 
@@ -22,10 +22,11 @@ Usage:
22
22
  """
23
23
 
24
24
  from datetime import datetime, timezone
25
- from typing import Any, Dict, Mapping, Optional, Sequence, Union
25
+ import re
26
+ from typing import Any, Dict, Mapping, Optional, Sequence, Union, cast
26
27
 
27
28
  TagSet = Dict[str, Optional[str]]
28
- FieldValue = Union[int, float, None]
29
+ FieldValue = Union[int, float, str, bool, None]
29
30
  FieldSet = Dict[str, FieldValue]
30
31
 
31
32
 
@@ -73,14 +74,83 @@ def validate_fields(fields: Any) -> None:
73
74
  if i is None:
74
75
  continue
75
76
 
76
- if isinstance(i, bool) or not isinstance(i, (int, float)):
77
+ if not isinstance(i, (int, float, str, bool)):
77
78
  raise ValueError(
78
- "Field set must contain only numeric values or None."
79
+ "Field set must contain only numeric, string, or bool values "
80
+ "or None."
79
81
  )
80
82
 
81
83
  return
82
84
 
83
85
 
86
+ _FIELD_STR_ESCAPES = {"\\": "\\\\", '"': '\\"', "\n": "\\n", "\r": "\\r"}
87
+ _FIELD_STR_UNESCAPES = {"\\": "\\", '"': '"', "n": "\n", "r": "\r"}
88
+
89
+
90
+ def _serialize_field_value(value: FieldValue, none_str: str) -> str:
91
+ """Serialize a field value to a string.
92
+
93
+ Numbers are written as floats, bools as "true"/"false", and strings in
94
+ double quotes with backslash, double quote, and line break characters
95
+ escaped so that a serialized value never spans multiple lines.
96
+
97
+ Args:
98
+ value: The field value.
99
+ none_str: The string that represents None.
100
+
101
+ Returns:
102
+ The serialized field value.
103
+ """
104
+ value_type = type(value)
105
+
106
+ if value_type is float:
107
+ return str(value)
108
+
109
+ if value_type is int:
110
+ return str(float(cast(int, value)))
111
+
112
+ if value is None:
113
+ return none_str
114
+
115
+ if isinstance(value, bool):
116
+ return "true" if value else "false"
117
+
118
+ if isinstance(value, str):
119
+ escaped = "".join(_FIELD_STR_ESCAPES.get(c, c) for c in value)
120
+ return f'"{escaped}"'
121
+
122
+ if isinstance(value, (int, float)):
123
+ return str(float(value))
124
+
125
+ raise TypeError(f"Unsupported field value type: {type(value).__name__}")
126
+
127
+
128
+ def _deserialize_non_numeric_field_value(value: str) -> FieldValue:
129
+ """Deserialize a field value that is not a number.
130
+
131
+ Args:
132
+ value: The serialized field value.
133
+
134
+ Returns:
135
+ A bool for "true"/"false", a str for a double-quoted value, and None
136
+ for anything else.
137
+ """
138
+ if value == "true":
139
+ return True
140
+
141
+ if value == "false":
142
+ return False
143
+
144
+ if len(value) >= 2 and value[0] == '"' and value[-1] == '"':
145
+ return re.sub(
146
+ r"\\(.)",
147
+ lambda m: _FIELD_STR_UNESCAPES.get(m.group(1), m.group(0)),
148
+ value[1:-1],
149
+ )
150
+
151
+ return None
152
+
153
+
84
154
  class Point:
85
155
  """Define the Point class.
86
156
 
@@ -291,9 +361,9 @@ class Point:
291
361
  try:
292
362
  p_fields[f_key] = float(f_value)
293
363
 
294
- # Value is None.
364
+ # Value is a bool, a string, or None.
295
365
  except Exception:
296
- p_fields[f_key] = None
366
+ p_fields[f_key] = _deserialize_non_numeric_field_value(f_value)
297
367
 
298
368
  i += 2
299
369
 
@@ -351,7 +421,7 @@ class Point:
351
421
  fields = (
352
422
  (
353
423
  f"{field_key_prefix}{k}",
354
- self._none_str if v is None else str(float(v)),
424
+ _serialize_field_value(v, self._none_str),
355
425
  )
356
426
  for k, v in self._fields.items()
357
427
  )
@@ -32,6 +32,42 @@ from .point import Point
32
32
 
33
33
  Query = Union["SimpleQuery", "CompoundQuery"]
34
34
 
35
+ _NE = operator.ne
36
+
37
+
38
+ class _HashableRHS:
39
+ """A hash key for RHS values that preserves bool identity."""
40
+
41
+ __slots__ = ("_kind", "_value", "_display_value")
42
+
43
+ def __init__(self, kind: Any, value: Any, display_value: Any) -> None:
44
+ self._kind = kind
45
+ self._value = value
46
+ self._display_value = display_value
47
+
48
+ def __hash__(self) -> int:
49
+ return hash((self._kind, self._value))
50
+
51
+ def __eq__(self, other: object) -> bool:
52
+ if not isinstance(other, _HashableRHS):
53
+ return False
54
+
55
+ return bool((self._kind, self._value) == (other._kind, other._value))
56
+
57
+ def __repr__(self) -> str:
58
+ return repr(self._display_value)
59
+
60
+
61
+ def _hashable_rhs(rhs: Any) -> Any:
62
+ """Return a hash key for RHS values that preserves bool identity."""
63
+ if isinstance(rhs, bool):
64
+ return _HashableRHS(bool, rhs, rhs)
65
+
66
+ if isinstance(rhs, (int, float)):
67
+ return _HashableRHS("number", float(rhs), rhs)
68
+
69
+ return _HashableRHS(type(rhs), rhs, rhs)
70
+
35
71
 
36
72
  class CompoundQuery:
37
73
  """A container class for simple and/or compound queries and an operator.
@@ -467,15 +503,25 @@ class BaseQuery:
467
503
  if (
468
504
  self._point_attr == "_fields"
469
505
  and rhs
470
- and not isinstance(rhs, (int, float))
506
+ and not isinstance(rhs, (int, float, str))
471
507
  ):
472
- raise TypeError("FieldQuery comparison value must be numeric.")
508
+ raise TypeError(
509
+ "FieldQuery comparison value must be numeric, string, or bool."
510
+ )
511
+
512
+ is_field_query = self._point_attr == "_fields"
513
+ rhs_is_bool = isinstance(rhs, bool)
473
514
 
474
515
  def test(x: Any) -> bool:
475
516
  """The test function from an operator and right-hand side."""
476
517
  if not test_against_rhs:
477
518
  return operator(x, *args) if args else operator(x)
478
519
 
520
+ # A bool field is not a number, so a bool is only ever equal to
521
+ # another bool and never orders against a non-bool.
522
+ if is_field_query and isinstance(x, bool) != rhs_is_bool:
523
+ return operator is _NE
524
+
479
525
  # Wrap this in a try/except block.
480
526
  # Some operators do not work against None types.
481
527
  # They should evaluate to False.
@@ -532,7 +578,7 @@ class BaseQuery:
532
578
  test_against_rhs=True,
533
579
  rhs=rhs,
534
580
  args=None,
535
- hashval=(self._point_attr, "==", self._path, rhs),
581
+ hashval=(self._point_attr, "==", self._path, _hashable_rhs(rhs)),
536
582
  )
537
583
 
538
584
  def __ne__(self, rhs: Any) -> SimpleQuery: # type: ignore
@@ -551,7 +597,7 @@ class BaseQuery:
551
597
  test_against_rhs=True,
552
598
  rhs=rhs,
553
599
  args=None,
554
- hashval=(self._point_attr, "!=", self._path, rhs),
600
+ hashval=(self._point_attr, "!=", self._path, _hashable_rhs(rhs)),
555
601
  )
556
602
 
557
603
  def __lt__(self, rhs: Any) -> SimpleQuery:
@@ -568,7 +614,7 @@ class BaseQuery:
568
614
  test_against_rhs=True,
569
615
  rhs=rhs,
570
616
  args=None,
571
- hashval=(self._point_attr, "<", self._path, rhs),
617
+ hashval=(self._point_attr, "<", self._path, _hashable_rhs(rhs)),
572
618
  )
573
619
 
574
620
  def __le__(self, rhs: Any) -> SimpleQuery:
@@ -585,7 +631,7 @@ class BaseQuery:
585
631
  test_against_rhs=True,
586
632
  rhs=rhs,
587
633
  args=None,
588
- hashval=(self._point_attr, "<=", self._path, rhs),
634
+ hashval=(self._point_attr, "<=", self._path, _hashable_rhs(rhs)),
589
635
  )
590
636
 
591
637
  def __gt__(self, rhs: Any) -> SimpleQuery:
@@ -602,7 +648,7 @@ class BaseQuery:
602
648
  test_against_rhs=True,
603
649
  rhs=rhs,
604
650
  args=None,
605
- hashval=(self._point_attr, ">", self._path, rhs),
651
+ hashval=(self._point_attr, ">", self._path, _hashable_rhs(rhs)),
606
652
  )
607
653
 
608
654
  def __ge__(self, rhs: Any) -> SimpleQuery:
@@ -619,7 +665,7 @@ class BaseQuery:
619
665
  test_against_rhs=True,
620
666
  rhs=rhs,
621
667
  args=None,
622
- hashval=(self._point_attr, ">=", self._path, rhs),
668
+ hashval=(self._point_attr, ">=", self._path, _hashable_rhs(rhs)),
623
669
  )
624
670
 
625
671
  def __and__(self, other: Any) -> None:
@@ -368,7 +368,9 @@ class CSVStorage(Storage):
368
368
 
369
369
  def _init_temp_storage(self) -> None:
370
370
  """Initialize temporary storage."""
371
- self._temp_handle = NamedTemporaryFile("w+t", newline="", delete=False)
371
+ self._temp_handle = NamedTemporaryFile(
372
+ "w+t", encoding=self._encoding, newline="", delete=False
373
+ )
372
374
 
373
375
  return
374
376
 
@@ -0,0 +1,3 @@
1
+ """Version."""
2
+
3
+ __version__ = "1.3.0" # pragma: no cover
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tinyflux
3
- Version: 1.2.1
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
@@ -1,3 +0,0 @@
1
- """Version."""
2
-
3
- __version__ = "1.2.1" # pragma: no cover
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes