ostruct-cli 0.5.0__py3-none-any.whl → 0.6.1__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.
ostruct/cli/cli.py CHANGED
@@ -5,7 +5,6 @@ import json
5
5
  import logging
6
6
  import os
7
7
  import sys
8
- from enum import Enum, IntEnum
9
8
  from typing import (
10
9
  Any,
11
10
  AsyncGenerator,
@@ -20,12 +19,11 @@ from typing import (
20
19
  TypeVar,
21
20
  Union,
22
21
  cast,
23
- get_origin,
24
22
  overload,
25
23
  )
26
24
 
27
25
  if sys.version_info >= (3, 11):
28
- from enum import StrEnum
26
+ pass
29
27
 
30
28
  from datetime import date, datetime, time
31
29
  from pathlib import Path
@@ -48,15 +46,7 @@ from openai_structured.errors import (
48
46
  StreamBufferError,
49
47
  )
50
48
  from openai_structured.model_registry import ModelRegistry
51
- from pydantic import (
52
- AnyUrl,
53
- BaseModel,
54
- ConfigDict,
55
- EmailStr,
56
- Field,
57
- ValidationError,
58
- create_model,
59
- )
49
+ from pydantic import AnyUrl, BaseModel, EmailStr, Field
60
50
  from pydantic.fields import FieldInfo as FieldInfoType
61
51
  from pydantic.functional_validators import BeforeValidator
62
52
  from pydantic.types import constr
@@ -69,11 +59,8 @@ from .. import __version__ # noqa: F401 - Used in package metadata
69
59
  from .errors import (
70
60
  CLIError,
71
61
  DirectoryNotFoundError,
72
- FieldDefinitionError,
73
62
  InvalidJSONError,
74
63
  ModelCreationError,
75
- ModelValidationError,
76
- NestedModelError,
77
64
  OstructFileNotFoundError,
78
65
  PathSecurityError,
79
66
  SchemaFileError,
@@ -86,6 +73,7 @@ from .errors import (
86
73
  VariableValueError,
87
74
  )
88
75
  from .file_utils import FileInfoList, collect_files
76
+ from .model_creation import _create_enum_type, create_dynamic_model
89
77
  from .path_utils import validate_path_mapping
90
78
  from .security import SecurityManager
91
79
  from .serialization import LogSerializer
@@ -97,6 +85,71 @@ from .token_utils import estimate_tokens_with_encoding
97
85
  DEFAULT_SYSTEM_PROMPT = "You are a helpful assistant."
98
86
 
99
87
 
88
+ # Validation functions
89
+ def pattern(regex: str) -> Any:
90
+ return constr(pattern=regex)
91
+
92
+
93
+ def min_length(length: int) -> Any:
94
+ return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
95
+
96
+
97
+ def max_length(length: int) -> Any:
98
+ return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
99
+
100
+
101
+ def ge(value: Union[int, float]) -> Any:
102
+ return BeforeValidator(lambda v: v if float(v) >= value else None)
103
+
104
+
105
+ def le(value: Union[int, float]) -> Any:
106
+ return BeforeValidator(lambda v: v if float(v) <= value else None)
107
+
108
+
109
+ def gt(value: Union[int, float]) -> Any:
110
+ return BeforeValidator(lambda v: v if float(v) > value else None)
111
+
112
+
113
+ def lt(value: Union[int, float]) -> Any:
114
+ return BeforeValidator(lambda v: v if float(v) < value else None)
115
+
116
+
117
+ def multiple_of(value: Union[int, float]) -> Any:
118
+ return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
119
+
120
+
121
+ def create_template_context(
122
+ files: Optional[
123
+ Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
124
+ ] = None,
125
+ variables: Optional[Dict[str, str]] = None,
126
+ json_variables: Optional[Dict[str, Any]] = None,
127
+ security_manager: Optional[SecurityManager] = None,
128
+ stdin_content: Optional[str] = None,
129
+ ) -> Dict[str, Any]:
130
+ """Create template context from files and variables."""
131
+ context: Dict[str, Any] = {}
132
+
133
+ # Add file variables
134
+ if files:
135
+ for name, file_list in files.items():
136
+ context[name] = file_list # Always keep FileInfoList wrapper
137
+
138
+ # Add simple variables
139
+ if variables:
140
+ context.update(variables)
141
+
142
+ # Add JSON variables
143
+ if json_variables:
144
+ context.update(json_variables)
145
+
146
+ # Add stdin if provided
147
+ if stdin_content is not None:
148
+ context["stdin"] = stdin_content
149
+
150
+ return context
151
+
152
+
100
153
  class CLIParams(TypedDict, total=False):
101
154
  """Type-safe CLI parameters."""
102
155
 
@@ -106,6 +159,9 @@ class CLIParams(TypedDict, total=False):
106
159
  dir: List[
107
160
  Tuple[str, str]
108
161
  ] # List of (name, dir) tuples from Click's nargs=2
162
+ patterns: List[
163
+ Tuple[str, str]
164
+ ] # List of (name, pattern) tuples from Click's nargs=2
109
165
  allowed_dirs: List[str]
110
166
  base_dir: str
111
167
  allowed_dir_file: Optional[str]
@@ -182,12 +238,6 @@ ItemType: TypeAlias = Type[BaseModel]
182
238
  ValueType: TypeAlias = Type[Any]
183
239
 
184
240
 
185
- def is_container_type(tp: Type[Any]) -> bool:
186
- """Check if a type is a container type (list, dict, etc.)."""
187
- origin = get_origin(tp)
188
- return origin in (list, dict)
189
-
190
-
191
241
  def _create_field(**kwargs: Any) -> FieldInfoType:
192
242
  """Create a Pydantic Field with the given kwargs."""
193
243
  field: FieldInfoType = Field(**kwargs)
@@ -796,7 +846,7 @@ def validate_schema_file(
796
846
  if not isinstance(schema, dict):
797
847
  msg = f"Schema in {path} must be a JSON object"
798
848
  logger.error(msg)
799
- raise SchemaValidationError(msg, schema_path=path)
849
+ raise SchemaValidationError(msg, context={"path": path})
800
850
 
801
851
  # Validate schema structure
802
852
  if "schema" in schema:
@@ -806,7 +856,7 @@ def validate_schema_file(
806
856
  if not isinstance(inner_schema, dict):
807
857
  msg = f"Inner schema in {path} must be a JSON object"
808
858
  logger.error(msg)
809
- raise SchemaValidationError(msg, schema_path=path)
859
+ raise SchemaValidationError(msg, context={"path": path})
810
860
  if verbose:
811
861
  logger.debug("Inner schema validated successfully")
812
862
  logger.debug(
@@ -821,7 +871,7 @@ def validate_schema_file(
821
871
  if "type" not in schema.get("schema", schema):
822
872
  msg = f"Schema in {path} must specify a type"
823
873
  logger.error(msg)
824
- raise SchemaValidationError(msg, schema_path=path)
874
+ raise SchemaValidationError(msg, context={"path": path})
825
875
 
826
876
  # Return the full schema including wrapper
827
877
  return schema
@@ -845,20 +895,22 @@ def collect_template_files(
845
895
  ValueError: If file mappings are invalid or files cannot be accessed
846
896
  """
847
897
  try:
848
- # Get files and directories from args - they are already tuples from Click's nargs=2
898
+ # Get files, directories, and patterns from args - they are already tuples from Click's nargs=2
849
899
  files = list(
850
900
  args.get("files", [])
851
901
  ) # List of (name, path) tuples from Click
852
902
  dirs = args.get("dir", []) # List of (name, dir) tuples from Click
903
+ patterns = args.get(
904
+ "patterns", []
905
+ ) # List of (name, pattern) tuples from Click
853
906
 
854
- # Collect files from directories
907
+ # Collect files from directories and patterns
855
908
  dir_files = collect_files(
856
- file_mappings=cast(
857
- List[Tuple[str, Union[str, Path]]], files
858
- ), # Cast to correct type
859
- dir_mappings=cast(
860
- List[Tuple[str, Union[str, Path]]], dirs
861
- ), # Cast to correct type
909
+ file_mappings=cast(List[Tuple[str, Union[str, Path]]], files),
910
+ dir_mappings=cast(List[Tuple[str, Union[str, Path]]], dirs),
911
+ pattern_mappings=cast(
912
+ List[Tuple[str, Union[str, Path]]], patterns
913
+ ),
862
914
  dir_recursive=args.get("recursive", False),
863
915
  security_manager=security_manager,
864
916
  )
@@ -872,8 +924,11 @@ def collect_template_files(
872
924
  # Let PathSecurityError propagate without wrapping
873
925
  raise
874
926
  except (FileNotFoundError, DirectoryNotFoundError) as e:
875
- # Wrap file-related errors
876
- raise ValueError(f"File access error: {e}")
927
+ # Convert FileNotFoundError to OstructFileNotFoundError
928
+ if isinstance(e, FileNotFoundError):
929
+ raise OstructFileNotFoundError(str(e))
930
+ # Let DirectoryNotFoundError propagate
931
+ raise
877
932
  except Exception as e:
878
933
  # Don't wrap InvalidJSONError
879
934
  if isinstance(e, InvalidJSONError):
@@ -975,53 +1030,6 @@ def collect_json_variables(args: CLIParams) -> Dict[str, Any]:
975
1030
  return variables
976
1031
 
977
1032
 
978
- def create_template_context(
979
- files: Optional[
980
- Dict[str, Union[FileInfoList, str, List[str], Dict[str, str]]]
981
- ] = None,
982
- variables: Optional[Dict[str, str]] = None,
983
- json_variables: Optional[Dict[str, Any]] = None,
984
- security_manager: Optional[SecurityManager] = None,
985
- stdin_content: Optional[str] = None,
986
- ) -> Dict[str, Any]:
987
- """Create template context from direct inputs.
988
-
989
- Args:
990
- files: Optional dictionary mapping names to FileInfoList objects
991
- variables: Optional dictionary of simple string variables
992
- json_variables: Optional dictionary of JSON variables
993
- security_manager: Optional security manager for path validation
994
- stdin_content: Optional content to use for stdin
995
-
996
- Returns:
997
- Template context dictionary
998
-
999
- Raises:
1000
- PathSecurityError: If any file paths violate security constraints
1001
- VariableError: If variable mappings are invalid
1002
- """
1003
- context: Dict[str, Any] = {}
1004
-
1005
- # Add file variables
1006
- if files:
1007
- for name, file_list in files.items():
1008
- context[name] = file_list # Always keep FileInfoList wrapper
1009
-
1010
- # Add simple variables
1011
- if variables:
1012
- context.update(variables)
1013
-
1014
- # Add JSON variables
1015
- if json_variables:
1016
- context.update(json_variables)
1017
-
1018
- # Add stdin if provided
1019
- if stdin_content is not None:
1020
- context["stdin"] = stdin_content
1021
-
1022
- return context
1023
-
1024
-
1025
1033
  async def create_template_context_from_args(
1026
1034
  args: CLIParams,
1027
1035
  security_manager: SecurityManager,
@@ -1076,8 +1084,11 @@ async def create_template_context_from_args(
1076
1084
  # Let PathSecurityError propagate without wrapping
1077
1085
  raise
1078
1086
  except (FileNotFoundError, DirectoryNotFoundError) as e:
1079
- # Wrap file-related errors
1080
- raise ValueError(f"File access error: {e}")
1087
+ # Convert FileNotFoundError to OstructFileNotFoundError
1088
+ if isinstance(e, FileNotFoundError):
1089
+ raise OstructFileNotFoundError(str(e))
1090
+ # Let DirectoryNotFoundError propagate
1091
+ raise
1081
1092
  except Exception as e:
1082
1093
  # Don't wrap InvalidJSONError
1083
1094
  if isinstance(e, InvalidJSONError):
@@ -1207,41 +1218,6 @@ def parse_json_var(var_str: str) -> Tuple[str, Any]:
1207
1218
  raise
1208
1219
 
1209
1220
 
1210
- def _create_enum_type(values: List[Any], field_name: str) -> Type[Enum]:
1211
- """Create an enum type from a list of values.
1212
-
1213
- Args:
1214
- values: List of enum values
1215
- field_name: Name of the field for enum type name
1216
-
1217
- Returns:
1218
- Created enum type
1219
- """
1220
- # Determine the value type
1221
- value_types = {type(v) for v in values}
1222
-
1223
- if len(value_types) > 1:
1224
- # Mixed types, use string representation
1225
- enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
1226
- return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
1227
- elif value_types == {int}:
1228
- # All integer values
1229
- enum_dict = {f"VALUE_{v}": v for v in values}
1230
- return type(f"{field_name.title()}Enum", (IntEnum,), enum_dict)
1231
- elif value_types == {str}:
1232
- # All string values
1233
- enum_dict = {v.upper().replace(" ", "_"): v for v in values}
1234
- if sys.version_info >= (3, 11):
1235
- return type(f"{field_name.title()}Enum", (StrEnum,), enum_dict)
1236
- else:
1237
- # Other types, use string representation
1238
- return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
1239
-
1240
- # Default case: treat as string enum
1241
- enum_dict = {f"VALUE_{i}": str(v) for i, v in enumerate(values)}
1242
- return type(f"{field_name.title()}Enum", (str, Enum), enum_dict)
1243
-
1244
-
1245
1221
  def handle_error(e: Exception) -> None:
1246
1222
  """Handle CLI errors and display appropriate messages.
1247
1223
 
@@ -1413,15 +1389,37 @@ async def stream_structured_output(
1413
1389
  ):
1414
1390
  yield chunk
1415
1391
 
1392
+ except APIResponseError as e:
1393
+ if "Invalid schema for response_format" in str(
1394
+ e
1395
+ ) and 'type: "array"' in str(e):
1396
+ error_msg = (
1397
+ "OpenAI API Schema Error: The schema must have a root type of 'object', not 'array'. "
1398
+ "To fix this:\n"
1399
+ "1. Wrap your array in an object property, e.g.:\n"
1400
+ " {\n"
1401
+ ' "type": "object",\n'
1402
+ ' "properties": {\n'
1403
+ ' "items": {\n'
1404
+ ' "type": "array",\n'
1405
+ ' "items": { ... your array items schema ... }\n'
1406
+ " }\n"
1407
+ " }\n"
1408
+ " }\n"
1409
+ "2. Make sure to update your template to handle the wrapper object."
1410
+ )
1411
+ logger.error(error_msg)
1412
+ raise InvalidResponseFormatError(error_msg)
1413
+ logger.error(f"API error: {e}")
1414
+ raise
1416
1415
  except (
1417
1416
  StreamInterruptedError,
1418
1417
  StreamBufferError,
1419
1418
  StreamParseError,
1420
- APIResponseError,
1421
1419
  EmptyResponseError,
1422
1420
  InvalidResponseFormatError,
1423
1421
  ) as e:
1424
- logger.error(f"Stream error: {e}")
1422
+ logger.error("Stream error: %s", str(e))
1425
1423
  raise
1426
1424
  finally:
1427
1425
  # Always ensure client is properly closed
@@ -1763,8 +1761,7 @@ async def execute_model(
1763
1761
  user_prompt=user_prompt,
1764
1762
  output_schema=output_model,
1765
1763
  output_file=args.get("output_file"),
1766
- **params, # Only pass validated model parameters
1767
- on_log=log_callback, # Pass logging callback separately
1764
+ on_log=log_callback,
1768
1765
  ):
1769
1766
  output_buffer.append(response)
1770
1767
 
@@ -1930,257 +1927,5 @@ __all__ = [
1930
1927
  ]
1931
1928
 
1932
1929
 
1933
- def create_dynamic_model(
1934
- schema: Dict[str, Any],
1935
- base_name: str = "DynamicModel",
1936
- show_schema: bool = False,
1937
- debug_validation: bool = False,
1938
- ) -> Type[BaseModel]:
1939
- """Create a Pydantic model from a JSON schema.
1940
-
1941
- Args:
1942
- schema: JSON schema to create model from
1943
- base_name: Name for the model class
1944
- show_schema: Whether to show the generated model schema
1945
- debug_validation: Whether to show detailed validation errors
1946
-
1947
- Returns:
1948
- Type[BaseModel]: The generated Pydantic model class
1949
-
1950
- Raises:
1951
- ModelValidationError: If the schema is invalid
1952
- """
1953
- if debug_validation:
1954
- logger.info("Creating dynamic model from schema:")
1955
- logger.info(json.dumps(schema, indent=2))
1956
-
1957
- try:
1958
- # Extract required fields
1959
- required: Set[str] = set(schema.get("required", []))
1960
-
1961
- # Handle our wrapper format if present
1962
- if "schema" in schema:
1963
- if debug_validation:
1964
- logger.info("Found schema wrapper, extracting inner schema")
1965
- logger.info(
1966
- "Original schema: %s", json.dumps(schema, indent=2)
1967
- )
1968
- inner_schema = schema["schema"]
1969
- if not isinstance(inner_schema, dict):
1970
- if debug_validation:
1971
- logger.info(
1972
- "Inner schema must be a dictionary, got %s",
1973
- type(inner_schema),
1974
- )
1975
- raise SchemaValidationError(
1976
- "Inner schema must be a dictionary"
1977
- )
1978
- if debug_validation:
1979
- logger.info("Using inner schema:")
1980
- logger.info(json.dumps(inner_schema, indent=2))
1981
- schema = inner_schema
1982
-
1983
- # Ensure schema has type field
1984
- if "type" not in schema:
1985
- if debug_validation:
1986
- logger.info("Schema missing type field, assuming object type")
1987
- schema["type"] = "object"
1988
-
1989
- # For non-object root schemas, create a wrapper model
1990
- if schema["type"] != "object":
1991
- if debug_validation:
1992
- logger.info(
1993
- "Converting non-object root schema to object wrapper"
1994
- )
1995
- schema = {
1996
- "type": "object",
1997
- "properties": {"value": schema},
1998
- "required": ["value"],
1999
- }
2000
-
2001
- # Create model configuration
2002
- config = ConfigDict(
2003
- title=schema.get("title", base_name),
2004
- extra=(
2005
- "forbid"
2006
- if schema.get("additionalProperties") is False
2007
- else "allow"
2008
- ),
2009
- validate_default=True,
2010
- use_enum_values=True,
2011
- arbitrary_types_allowed=True,
2012
- json_schema_extra={
2013
- k: v
2014
- for k, v in schema.items()
2015
- if k
2016
- not in {
2017
- "type",
2018
- "properties",
2019
- "required",
2020
- "title",
2021
- "description",
2022
- "additionalProperties",
2023
- "readOnly",
2024
- }
2025
- },
2026
- )
2027
-
2028
- if debug_validation:
2029
- logger.info("Created model configuration:")
2030
- logger.info(" Title: %s", config.get("title"))
2031
- logger.info(" Extra: %s", config.get("extra"))
2032
- logger.info(
2033
- " Validate Default: %s", config.get("validate_default")
2034
- )
2035
- logger.info(" Use Enum Values: %s", config.get("use_enum_values"))
2036
- logger.info(
2037
- " Arbitrary Types: %s", config.get("arbitrary_types_allowed")
2038
- )
2039
- logger.info(
2040
- " JSON Schema Extra: %s", config.get("json_schema_extra")
2041
- )
2042
-
2043
- # Process schema properties into fields
2044
- properties = schema.get("properties", {})
2045
- required = schema.get("required", [])
2046
-
2047
- field_definitions: Dict[str, Tuple[Type[Any], FieldInfoType]] = {}
2048
- for field_name, field_schema in properties.items():
2049
- if debug_validation:
2050
- logger.info("Processing field %s:", field_name)
2051
- logger.info(" Schema: %s", json.dumps(field_schema, indent=2))
2052
-
2053
- try:
2054
- python_type, field = _get_type_with_constraints(
2055
- field_schema, field_name, base_name
2056
- )
2057
-
2058
- # Handle optional fields
2059
- if field_name not in required:
2060
- if debug_validation:
2061
- logger.info(
2062
- "Field %s is optional, wrapping in Optional",
2063
- field_name,
2064
- )
2065
- field_type = cast(Type[Any], Optional[python_type])
2066
- else:
2067
- field_type = python_type
2068
- if debug_validation:
2069
- logger.info("Field %s is required", field_name)
2070
-
2071
- # Create field definition
2072
- field_definitions[field_name] = (field_type, field)
2073
-
2074
- if debug_validation:
2075
- logger.info("Successfully created field definition:")
2076
- logger.info(" Name: %s", field_name)
2077
- logger.info(" Type: %s", str(field_type))
2078
- logger.info(" Required: %s", field_name in required)
2079
-
2080
- except (FieldDefinitionError, NestedModelError) as e:
2081
- if debug_validation:
2082
- logger.error("Error creating field %s:", field_name)
2083
- logger.error(" Error type: %s", type(e).__name__)
2084
- logger.error(" Error message: %s", str(e))
2085
- raise ModelValidationError(base_name, [str(e)])
2086
-
2087
- # Create the model with the fields
2088
- field_defs: Dict[str, Any] = {
2089
- name: (
2090
- (
2091
- cast(Type[Any], field_type)
2092
- if is_container_type(field_type)
2093
- else field_type
2094
- ),
2095
- field,
2096
- )
2097
- for name, (field_type, field) in field_definitions.items()
2098
- }
2099
- model: Type[BaseModel] = create_model(
2100
- base_name, __config__=config, **field_defs
2101
- )
2102
-
2103
- # Set the model config after creation
2104
- model.model_config = config
2105
-
2106
- if debug_validation:
2107
- logger.info("Successfully created model: %s", model.__name__)
2108
- logger.info("Model config: %s", dict(model.model_config))
2109
- logger.info(
2110
- "Model schema: %s",
2111
- json.dumps(model.model_json_schema(), indent=2),
2112
- )
2113
-
2114
- # Validate the model's JSON schema
2115
- try:
2116
- model.model_json_schema()
2117
- except ValidationError as e:
2118
- if debug_validation:
2119
- logger.error("Schema validation failed:")
2120
- logger.error(" Error type: %s", type(e).__name__)
2121
- logger.error(" Error message: %s", str(e))
2122
- validation_errors = (
2123
- [str(err) for err in e.errors()]
2124
- if hasattr(e, "errors")
2125
- else [str(e)]
2126
- )
2127
- raise ModelValidationError(base_name, validation_errors)
2128
-
2129
- return model
2130
-
2131
- except Exception as e:
2132
- if debug_validation:
2133
- logger.error("Failed to create model:")
2134
- logger.error(" Error type: %s", type(e).__name__)
2135
- logger.error(" Error message: %s", str(e))
2136
- if hasattr(e, "__cause__"):
2137
- logger.error(" Caused by: %s", str(e.__cause__))
2138
- if hasattr(e, "__context__"):
2139
- logger.error(" Context: %s", str(e.__context__))
2140
- if hasattr(e, "__traceback__"):
2141
- import traceback
2142
-
2143
- logger.error(
2144
- " Traceback:\n%s",
2145
- "".join(traceback.format_tb(e.__traceback__)),
2146
- )
2147
- raise ModelCreationError(
2148
- f"Failed to create model '{base_name}': {str(e)}"
2149
- )
2150
-
2151
-
2152
- # Validation functions
2153
- def pattern(regex: str) -> Any:
2154
- return constr(pattern=regex)
2155
-
2156
-
2157
- def min_length(length: int) -> Any:
2158
- return BeforeValidator(lambda v: v if len(str(v)) >= length else None)
2159
-
2160
-
2161
- def max_length(length: int) -> Any:
2162
- return BeforeValidator(lambda v: v if len(str(v)) <= length else None)
2163
-
2164
-
2165
- def ge(value: Union[int, float]) -> Any:
2166
- return BeforeValidator(lambda v: v if float(v) >= value else None)
2167
-
2168
-
2169
- def le(value: Union[int, float]) -> Any:
2170
- return BeforeValidator(lambda v: v if float(v) <= value else None)
2171
-
2172
-
2173
- def gt(value: Union[int, float]) -> Any:
2174
- return BeforeValidator(lambda v: v if float(v) > value else None)
2175
-
2176
-
2177
- def lt(value: Union[int, float]) -> Any:
2178
- return BeforeValidator(lambda v: v if float(v) < value else None)
2179
-
2180
-
2181
- def multiple_of(value: Union[int, float]) -> Any:
2182
- return BeforeValidator(lambda v: v if float(v) % value == 0 else None)
2183
-
2184
-
2185
1930
  if __name__ == "__main__":
2186
1931
  main()