ethspecify 0.3.1__tar.gz → 0.3.3__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ethspecify
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A utility for processing Ethereum specification tags.
5
5
  Home-page: https://github.com/jtraglia/ethspecify
6
6
  Author: Justin Traglia
@@ -3,7 +3,7 @@ import json
3
3
  import os
4
4
  import sys
5
5
 
6
- from .core import grep, replace_spec_tags, get_pyspec, get_latest_fork, get_spec_item_history, load_config, run_checks, sort_specref_yaml, generate_specref_files
6
+ from .core import grep, replace_spec_tags, get_pyspec, get_latest_fork, get_spec_item_history, load_config, run_checks, sort_specref_yaml, generate_specref_files, get_yaml_filename_for_spec_attr, get_spec_attr_and_name, add_missing_entries_to_yaml
7
7
 
8
8
 
9
9
  def process(args):
@@ -16,20 +16,37 @@ def process(args):
16
16
  # Load config once from the project directory
17
17
  config = load_config(project_dir)
18
18
 
19
- # Process spec tags in files
20
- for f in grep(project_dir, r"<spec\b.*?>", args.exclude):
21
- print(f"Processing file: {f}")
22
- replace_spec_tags(f, config)
23
-
24
- # Sort specref YAML files if they exist in config
19
+ # Check if auto_add_missing_entries and auto_standardize_names are enabled
25
20
  specrefs_config = config.get('specrefs', {})
26
21
  if isinstance(specrefs_config, dict):
22
+ auto_add_missing = specrefs_config.get('auto_add_missing_entries', False)
23
+ auto_standardize_names = specrefs_config.get('auto_standardize_names', False)
27
24
  specrefs_files = specrefs_config.get('files', [])
28
25
  elif isinstance(specrefs_config, list):
26
+ auto_add_missing = False
27
+ auto_standardize_names = False
29
28
  specrefs_files = specrefs_config
30
29
  else:
30
+ auto_add_missing = False
31
+ auto_standardize_names = False
31
32
  specrefs_files = []
32
33
 
34
+ # Process spec tags in files
35
+ for f in grep(project_dir, r"<spec\b.*?>", args.exclude):
36
+ print(f"Processing file: {f}")
37
+ replace_spec_tags(f, config)
38
+
39
+ # Add missing spec items to YAML files if enabled
40
+ if auto_add_missing:
41
+ from .core import add_missing_spec_items_to_yaml_files
42
+ add_missing_spec_items_to_yaml_files(project_dir, config, specrefs_files)
43
+
44
+ # Update entry names to <spec_item>#<fork> format if enabled
45
+ if auto_standardize_names:
46
+ from .core import update_entry_names_in_yaml_files
47
+ update_entry_names_in_yaml_files(project_dir, specrefs_files)
48
+
49
+ # Sort specref YAML files if they exist in config
33
50
  for yaml_file in specrefs_files:
34
51
  yaml_path = os.path.join(project_dir, yaml_file)
35
52
  if os.path.exists(yaml_path):
@@ -89,7 +89,8 @@ def validate_exception_items(exceptions, version):
89
89
  break
90
90
 
91
91
  if not item_found:
92
- errors.append(f"invalid key: {exception_key}.{item_name}{"#" + fork if fork else ""}")
92
+ fork_suffix = f"#{fork}" if fork else ""
93
+ errors.append(f"invalid key: {exception_key}.{item_name}{fork_suffix}")
93
94
 
94
95
  if errors:
95
96
  error_msg = "Invalid exception items in configuration:\n" + "\n".join(f" - {e}" for e in errors)
@@ -588,9 +589,18 @@ def sort_specref_yaml(yaml_file):
588
589
  with open(yaml_file, 'r') as f:
589
590
  content_str = f.read()
590
591
 
591
- # Try to fix common YAML issues with unquoted search strings containing colons
592
- # This is the same fix used in check_source_files
593
- content_str = re.sub(r'(\s+search:\s+)([^"\n]+:)(\s*$)', r'\1"\2"\3', content_str, flags=re.MULTILINE)
592
+ # Extract search values that originally had single or double quotes before YAML parsing
593
+ # This preserves the original quoting style exactly
594
+ single_quoted_searches = set()
595
+ for match in re.finditer(r"search:\s*'([^']*)'", content_str):
596
+ single_quoted_searches.add(match.group(1))
597
+ double_quoted_searches = set()
598
+ for match in re.finditer(r'search:\s*"([^"]*)"', content_str):
599
+ double_quoted_searches.add(match.group(1))
600
+
601
+ # Temporarily quote unquoted search strings with colons so YAML can parse them
602
+ # This doesn't affect the output - we restore original quoting when writing
603
+ content_str = re.sub(r'(\s+search:\s+)([^"\'\n]+:)(\s*$)', r'\1"\2"\3', content_str, flags=re.MULTILINE)
594
604
 
595
605
  try:
596
606
  content = yaml.safe_load(content_str)
@@ -671,16 +681,11 @@ def sort_specref_yaml(yaml_file):
671
681
  output_lines.append(f" - file: {source.get('file', '')}")
672
682
  if 'search' in source:
673
683
  search_val = source['search']
674
- # Only quote if:
675
- # 1. Contains a colon followed by space or end of string (YAML mapping issue)
676
- # 2. Not already quoted
677
- # 3. Doesn't contain internal quotes (like regex patterns)
678
- if ((':' in search_val or search_val.endswith(':')) and
679
- not (search_val.startswith('"') and search_val.endswith('"')) and
680
- '"' not in search_val):
681
- # Only quote simple strings with colons, not complex patterns
682
- if not any(char in search_val for char in ['\\', '*', '|', '[', ']', '(', ')']):
683
- search_val = f'"{search_val}"'
684
+ # Preserve original quoting style exactly
685
+ if search_val in single_quoted_searches:
686
+ search_val = f"'{search_val}'"
687
+ elif search_val in double_quoted_searches:
688
+ search_val = f'"{search_val}"'
684
689
  output_lines.append(f" search: {search_val}")
685
690
  if 'regex' in source:
686
691
  # Keep boolean values lowercase for consistency
@@ -724,6 +729,9 @@ def replace_spec_tags(file_path, config=None):
724
729
  re.DOTALL
725
730
  )
726
731
 
732
+ # Collect processed spec items for potential YAML updates
733
+ processed_items = []
734
+
727
735
  def rebuild_opening_tag(attributes, hash_value):
728
736
  # Rebuild a fresh opening tag from attributes, overriding any existing hash.
729
737
  new_opening = "<spec"
@@ -762,6 +770,17 @@ def replace_spec_tags(file_path, config=None):
762
770
  spec = get_spec(attributes, preset, fork, version)
763
771
  hash_value = hashlib.sha256(spec.encode('utf-8')).hexdigest()[:8]
764
772
 
773
+ # Collect this item for potential YAML updates
774
+ processed_items.append({
775
+ 'attributes': attributes,
776
+ 'preset': preset,
777
+ 'fork': fork,
778
+ 'style': style,
779
+ 'version': version,
780
+ 'spec': spec,
781
+ 'hash': hash_value
782
+ })
783
+
765
784
  if style == "hash":
766
785
  # Rebuild a fresh self-closing tag.
767
786
  updated_tag = rebuild_self_closing_tag(attributes, hash_value)
@@ -786,6 +805,169 @@ def replace_spec_tags(file_path, config=None):
786
805
  with open(file_path, 'w') as file:
787
806
  file.write(updated_content)
788
807
 
808
+ # Return processed items for potential YAML updates
809
+ return processed_items
810
+
811
+
812
+ def get_yaml_filename_for_spec_attr(spec_attr):
813
+ """Map spec attribute to YAML filename."""
814
+ attr_to_file = {
815
+ 'fn': 'functions.yml',
816
+ 'function': 'functions.yml',
817
+ 'constant_var': 'constants.yml',
818
+ 'config_var': 'configs.yml',
819
+ 'preset_var': 'presets.yml',
820
+ 'container': 'containers.yml',
821
+ 'ssz_object': 'containers.yml',
822
+ 'dataclass': 'dataclasses.yml',
823
+ 'custom_type': 'types.yml',
824
+ }
825
+ return attr_to_file.get(spec_attr)
826
+
827
+
828
+ def get_spec_attr_and_name(attributes):
829
+ """Extract the spec attribute and item name from tag attributes."""
830
+ spec_attrs = ['fn', 'function', 'constant_var', 'config_var', 'preset_var',
831
+ 'container', 'ssz_object', 'dataclass', 'custom_type']
832
+ for attr in spec_attrs:
833
+ if attr in attributes:
834
+ return attr, attributes[attr]
835
+ return None, None
836
+
837
+
838
+ def load_yaml_entries(yaml_file):
839
+ """Load existing entries from a YAML file."""
840
+ if not os.path.exists(yaml_file):
841
+ return []
842
+
843
+ try:
844
+ with open(yaml_file, 'r') as f:
845
+ content_str = f.read()
846
+
847
+ # Try to fix common YAML issues with unquoted search strings containing colons
848
+ content_str = re.sub(r'(\s+search:\s+)([^"\n]+:)(\s*$)', r'\1"\2"\3', content_str, flags=re.MULTILINE)
849
+
850
+ try:
851
+ content = yaml.safe_load(content_str)
852
+ except yaml.YAMLError:
853
+ content = yaml.load(content_str, Loader=yaml.FullLoader)
854
+
855
+ if isinstance(content, list):
856
+ return content
857
+ return []
858
+ except (yaml.YAMLError, IOError):
859
+ return []
860
+
861
+
862
+ def extract_spec_tag_key(spec_content):
863
+ """Extract a unique key from spec tag to identify duplicates."""
864
+ if not spec_content:
865
+ return None
866
+
867
+ # Extract the opening spec tag
868
+ match = re.search(r'<spec\b([^>]*)>', spec_content)
869
+ if not match:
870
+ return None
871
+
872
+ # Extract attributes from the tag
873
+ attributes = extract_attributes(match.group(0))
874
+
875
+ # Build a key from the spec attribute and fork
876
+ # e.g., "constant_var:DOMAIN_PTC_ATTESTER:fork:gloas"
877
+ key_parts = []
878
+ for attr in ['fn', 'function', 'constant_var', 'config_var', 'preset_var',
879
+ 'container', 'ssz_object', 'dataclass', 'custom_type']:
880
+ if attr in attributes:
881
+ key_parts.append(f"{attr}:{attributes[attr]}")
882
+ break
883
+
884
+ if 'fork' in attributes:
885
+ key_parts.append(f"fork:{attributes['fork']}")
886
+
887
+ return ':'.join(key_parts) if key_parts else None
888
+
889
+
890
+ def add_missing_entries_to_yaml(yaml_file, new_entries):
891
+ """Add new entries to a YAML file and sort it."""
892
+ if not new_entries:
893
+ return
894
+
895
+ # Extract search values that originally had single or double quotes before YAML parsing
896
+ # This preserves the original quoting style exactly
897
+ single_quoted_searches = set()
898
+ double_quoted_searches = set()
899
+ if os.path.exists(yaml_file):
900
+ with open(yaml_file, 'r') as f:
901
+ content_str = f.read()
902
+ for match in re.finditer(r"search:\s*'([^']*)'", content_str):
903
+ single_quoted_searches.add(match.group(1))
904
+ for match in re.finditer(r'search:\s*"([^"]*)"', content_str):
905
+ double_quoted_searches.add(match.group(1))
906
+
907
+ # Load existing entries
908
+ existing_entries = load_yaml_entries(yaml_file)
909
+
910
+ # Build a set of existing spec tag keys
911
+ existing_spec_keys = set()
912
+ for entry in existing_entries:
913
+ if isinstance(entry, dict) and 'spec' in entry:
914
+ spec_key = extract_spec_tag_key(entry['spec'])
915
+ if spec_key:
916
+ existing_spec_keys.add(spec_key)
917
+
918
+ # Filter out entries that already exist (based on spec tag, not name)
919
+ entries_to_add = []
920
+ for entry in new_entries:
921
+ spec_key = extract_spec_tag_key(entry.get('spec', ''))
922
+ if spec_key and spec_key not in existing_spec_keys:
923
+ entries_to_add.append(entry)
924
+ existing_spec_keys.add(spec_key) # Avoid duplicates within new entries
925
+
926
+ if not entries_to_add:
927
+ return
928
+
929
+ # Combine and write
930
+ all_entries = existing_entries + entries_to_add
931
+
932
+ # Ensure directory exists
933
+ os.makedirs(os.path.dirname(yaml_file) if os.path.dirname(yaml_file) else '.', exist_ok=True)
934
+
935
+ # Write combined entries using the same format as generate_specref_files
936
+ with open(yaml_file, 'w') as f:
937
+ for i, entry in enumerate(all_entries):
938
+ if i > 0:
939
+ f.write('\n')
940
+ f.write(f'- name: {entry["name"]}\n')
941
+ if 'sources' in entry:
942
+ if isinstance(entry['sources'], list) and len(entry['sources']) == 0:
943
+ f.write(' sources: []\n')
944
+ else:
945
+ f.write(' sources:\n')
946
+ for source in entry['sources']:
947
+ if isinstance(source, dict):
948
+ f.write(f' - file: {source.get("file", "")}\n')
949
+ if 'search' in source:
950
+ search_val = source["search"]
951
+ # Preserve original quoting style exactly
952
+ if search_val in single_quoted_searches:
953
+ search_val = f"'{search_val}'"
954
+ elif search_val in double_quoted_searches:
955
+ search_val = f'"{search_val}"'
956
+ f.write(f' search: {search_val}\n')
957
+ if 'regex' in source:
958
+ f.write(f' regex: {source["regex"]}\n')
959
+ else:
960
+ f.write(f' - {source}\n')
961
+ if 'spec' in entry:
962
+ f.write(' spec: |\n')
963
+ for line in entry['spec'].split('\n'):
964
+ f.write(f' {line}\n')
965
+
966
+ # Sort the file
967
+ sort_specref_yaml(yaml_file)
968
+
969
+ print(f"Added {len(entries_to_add)} new entries to {yaml_file}")
970
+
789
971
 
790
972
  def check_source_files(yaml_file, project_root, exceptions=None):
791
973
  """
@@ -1520,6 +1702,226 @@ def run_checks(project_dir, config):
1520
1702
  return overall_success, results
1521
1703
 
1522
1704
 
1705
+ def update_entry_names_in_yaml_files(project_dir, specrefs_files):
1706
+ """
1707
+ Update all entry names to use the format <spec_item>#<fork>.
1708
+ """
1709
+ for yaml_file in specrefs_files:
1710
+ yaml_path = os.path.join(project_dir, yaml_file)
1711
+
1712
+ if not os.path.exists(yaml_path):
1713
+ continue
1714
+
1715
+ # Extract search values that originally had single or double quotes before YAML parsing
1716
+ # This preserves the original quoting style exactly
1717
+ single_quoted_searches = set()
1718
+ double_quoted_searches = set()
1719
+ with open(yaml_path, 'r') as f:
1720
+ content_str = f.read()
1721
+ for match in re.finditer(r"search:\s*'([^']*)'", content_str):
1722
+ single_quoted_searches.add(match.group(1))
1723
+ for match in re.finditer(r'search:\s*"([^"]*)"', content_str):
1724
+ double_quoted_searches.add(match.group(1))
1725
+
1726
+ # Load existing entries
1727
+ existing_entries = load_yaml_entries(yaml_path)
1728
+ if not existing_entries:
1729
+ continue
1730
+
1731
+ updated = False
1732
+ for entry in existing_entries:
1733
+ if not isinstance(entry, dict) or 'spec' not in entry:
1734
+ continue
1735
+
1736
+ # Extract spec tag attributes
1737
+ spec_content = entry['spec']
1738
+ match = re.search(r'<spec\b([^>]*)>', spec_content)
1739
+ if not match:
1740
+ continue
1741
+
1742
+ attributes = extract_attributes(match.group(0))
1743
+
1744
+ # Get the spec item name and fork
1745
+ spec_attr, item_name = get_spec_attr_and_name(attributes)
1746
+ fork = attributes.get('fork')
1747
+
1748
+ if item_name and fork:
1749
+ # Build the expected name
1750
+ expected_name = f'{item_name}#{fork}'
1751
+
1752
+ # Update if different
1753
+ if entry.get('name') != expected_name:
1754
+ entry['name'] = expected_name
1755
+ updated = True
1756
+
1757
+ # Write back if updated
1758
+ if updated:
1759
+ with open(yaml_path, 'w') as f:
1760
+ for i, entry in enumerate(existing_entries):
1761
+ if i > 0:
1762
+ f.write('\n')
1763
+ f.write(f'- name: {entry["name"]}\n')
1764
+ if 'sources' in entry:
1765
+ if isinstance(entry['sources'], list) and len(entry['sources']) == 0:
1766
+ f.write(' sources: []\n')
1767
+ else:
1768
+ f.write(' sources:\n')
1769
+ for source in entry['sources']:
1770
+ if isinstance(source, dict):
1771
+ f.write(f' - file: {source.get("file", "")}\n')
1772
+ if 'search' in source:
1773
+ search_val = source["search"]
1774
+ # Preserve original quoting style exactly
1775
+ if search_val in single_quoted_searches:
1776
+ search_val = f"'{search_val}'"
1777
+ elif search_val in double_quoted_searches:
1778
+ search_val = f'"{search_val}"'
1779
+ f.write(f' search: {search_val}\n')
1780
+ if 'regex' in source:
1781
+ f.write(f' regex: {source["regex"]}\n')
1782
+ else:
1783
+ f.write(f' - {source}\n')
1784
+ if 'spec' in entry:
1785
+ f.write(' spec: |\n')
1786
+ for line in entry['spec'].split('\n'):
1787
+ f.write(f' {line}\n')
1788
+
1789
+ # Sort the file
1790
+ sort_specref_yaml(yaml_path)
1791
+ print(f"Updated entry names in {yaml_file}")
1792
+
1793
+
1794
+ def add_missing_spec_items_to_yaml_files(project_dir, config, specrefs_files):
1795
+ """
1796
+ Add missing spec items to existing YAML files.
1797
+ Ensures all spec items from the specification exist in YAML files with sources: []
1798
+ """
1799
+ version = config.get('version', 'nightly')
1800
+ preset = 'mainnet' # Could make this configurable
1801
+
1802
+ # Get all spec items
1803
+ pyspec = get_pyspec(version)
1804
+ if preset not in pyspec:
1805
+ print(f"Error: Preset '{preset}' not found")
1806
+ return
1807
+
1808
+ # Get all forks in chronological order, excluding EIP forks
1809
+ all_forks = sorted(
1810
+ [fork for fork in pyspec[preset].keys() if not fork.startswith("eip")],
1811
+ key=lambda x: (x != "phase0", x)
1812
+ )
1813
+
1814
+ # Map YAML filenames to category keys and spec attribute names
1815
+ filename_to_category = {
1816
+ 'constants.yml': ('constant_vars', 'constant_var'),
1817
+ 'configs.yml': ('config_vars', 'config_var'),
1818
+ 'presets.yml': ('preset_vars', 'preset_var'),
1819
+ 'functions.yml': ('functions', 'fn'),
1820
+ 'containers.yml': ('ssz_objects', 'container'),
1821
+ 'dataclasses.yml': ('dataclasses', 'dataclass'),
1822
+ 'types.yml': ('custom_types', 'custom_type'),
1823
+ }
1824
+
1825
+ for yaml_file in specrefs_files:
1826
+ yaml_path = os.path.join(project_dir, yaml_file)
1827
+ yaml_basename = os.path.basename(yaml_file)
1828
+
1829
+ if yaml_basename not in filename_to_category:
1830
+ continue
1831
+
1832
+ category, spec_attr = filename_to_category[yaml_basename]
1833
+
1834
+ # Collect all items in this category organized by name and fork
1835
+ items_by_name = {}
1836
+ for fork in all_forks:
1837
+ if fork not in pyspec[preset]:
1838
+ continue
1839
+ fork_data = pyspec[preset][fork]
1840
+
1841
+ if category not in fork_data:
1842
+ continue
1843
+
1844
+ for item_name, item_data in fork_data[category].items():
1845
+ if item_name not in items_by_name:
1846
+ items_by_name[item_name] = []
1847
+ items_by_name[item_name].append((fork, item_data))
1848
+
1849
+ # Build entries for missing items
1850
+ new_entries = []
1851
+ for item_name in sorted(items_by_name.keys()):
1852
+ forks_data = items_by_name[item_name]
1853
+
1854
+ # Find all unique versions of this item (where content differs)
1855
+ versions = [] # List of (fork, item_data, spec_content)
1856
+ prev_content = None
1857
+
1858
+ for fork, item_data in forks_data:
1859
+ # Build the spec content based on category
1860
+ if category == 'functions':
1861
+ spec_content = item_data
1862
+ elif category in ['constant_vars', 'config_vars', 'preset_vars']:
1863
+ # item_data is a list: [type, value, ...]
1864
+ if isinstance(item_data, (list, tuple)) and len(item_data) >= 2:
1865
+ type_info = item_data[0]
1866
+ value = item_data[1]
1867
+ if type_info:
1868
+ spec_content = f"{item_name}: {type_info} = {value}"
1869
+ else:
1870
+ spec_content = f"{item_name} = {value}"
1871
+ else:
1872
+ spec_content = str(item_data)
1873
+ elif category == 'ssz_objects':
1874
+ spec_content = item_data
1875
+ elif category == 'dataclasses':
1876
+ spec_content = item_data.replace("@dataclass\n", "")
1877
+ elif category == 'custom_types':
1878
+ # custom_types are simple type aliases: TypeName = SomeType
1879
+ spec_content = f"{item_name} = {item_data}"
1880
+ else:
1881
+ spec_content = str(item_data)
1882
+
1883
+ # Only add this version if it's different from the previous one
1884
+ if prev_content is None or spec_content != prev_content:
1885
+ versions.append((fork, item_data, spec_content))
1886
+ prev_content = spec_content
1887
+
1888
+ # Create entries based on number of unique versions
1889
+ use_fork_suffix = len(versions) > 1
1890
+
1891
+ for idx, (fork, item_data, spec_content) in enumerate(versions):
1892
+ # Calculate hash of current version
1893
+ hash_value = hashlib.sha256(spec_content.encode('utf-8')).hexdigest()[:8]
1894
+
1895
+ # For multiple versions after the first, use diff style
1896
+ if use_fork_suffix and idx > 0:
1897
+ # Get previous version for diff
1898
+ prev_fork, _, prev_spec_content = versions[idx - 1]
1899
+
1900
+ # Generate diff
1901
+ diff_content = diff(prev_fork, strip_comments(prev_spec_content), fork, strip_comments(spec_content))
1902
+
1903
+ # Build spec tag with style="diff"
1904
+ spec_tag = f'<spec {spec_attr}="{item_name}" fork="{fork}" style="diff" hash="{hash_value}">'
1905
+ content = diff_content
1906
+ else:
1907
+ # Build spec tag without style="diff"
1908
+ spec_tag = f'<spec {spec_attr}="{item_name}" fork="{fork}" hash="{hash_value}">'
1909
+ content = spec_content
1910
+
1911
+ # Create entry
1912
+ entry_name = f'{item_name}#{fork}' if use_fork_suffix else item_name
1913
+ entry = {
1914
+ 'name': entry_name,
1915
+ 'sources': [],
1916
+ 'spec': f'{spec_tag}\n{content}\n</spec>'
1917
+ }
1918
+ new_entries.append(entry)
1919
+
1920
+ # Add missing entries to the YAML file
1921
+ if new_entries:
1922
+ add_missing_entries_to_yaml(yaml_path, new_entries)
1923
+
1924
+
1523
1925
  def generate_specref_files(output_dir, version="nightly", preset="mainnet"):
1524
1926
  """
1525
1927
  Generate specref YAML files without sources for manual mapping.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ethspecify
3
- Version: 0.3.1
3
+ Version: 0.3.3
4
4
  Summary: A utility for processing Ethereum specification tags.
5
5
  Home-page: https://github.com/jtraglia/ethspecify
6
6
  Author: Justin Traglia
@@ -8,7 +8,7 @@ long_description = (this_directory / "README.md").read_text(encoding="utf-8")
8
8
 
9
9
  setup(
10
10
  name="ethspecify",
11
- version="0.3.1",
11
+ version="0.3.3",
12
12
  description="A utility for processing Ethereum specification tags.",
13
13
  long_description=long_description,
14
14
  long_description_content_type="text/markdown",
File without changes
File without changes
File without changes