diff --git a/TODO.md b/TODO.md index 3673bc8..2cd183b 100644 --- a/TODO.md +++ b/TODO.md @@ -1,6 +1,35 @@ # TODO -### For backwards compat, allow indexing a blueprintable to point to its `_root[_root_item]` sub-index instead of it's true `_root`? +### Redo validation (again) +Swapping to Pydantic was very illuminating in the benefits that it can provide: + +* Ergonomic class definitions to define schemas (very nice) +* Being able to inject custom functions at any point of the validation process to massage inputs, discredit validation, and add new criteria (possibly on the fly!) This is probably going to be required by any further implementation going forward +* All of these validation functions are localized to the classes that use them, and everything is in one place; only a single method has to be called for any member modification. +* Validation backend is written in Rust for speed (tempting) + +However, it is still not quite entirely perfect: + +* RootModel syntax cannot be serialized if not in the correct model format (unacceptable, currently this is sidestepped but suboptimal) +* RootModel syntax is unwieldly; everything is accessed via it's `root` attribute and any methods that you would want to use on `root` have to be reimplemented in the parent class +* I HAVE to use RootModels if I want to be able to reliably validate these members (and ideally propagate their `is_valid` flags) +* Per instance validate assignment is not permitted (even though totally functional), meaning I have to use the model's backend which might be subject to change +* No in-built handling for warnings, which ideally would behave very similarly to errors as Pydantic currently implements them + +Based on my search, I can't think of a validation library that has all of these features at once, implying that I would have to roll my own. I'm not really looking forward to this, and especially not to *testing* it, so if there is one out there please message me. + +--- + +### Validation caching +Ideally, whether or not a entity or blueprint is considered valid can be retained as long as the entity does not change after validation. For example, if you validate a single entity, and then add that entity to a blueprint 1000 times, you only have to validate the attributes of the blueprint itself, since the entities are guaranteed to already be in a valid state. Ideally, each exportable object would have a `is_valid` attribute which would be set to false when an attribute is set, which can then be quickly checked in any parent +`validate()` function. + +### Integrate with `mypy` + +### Revamp the `add_x` data functions so that they support more features +* Inline sorting +* Support additional keyword arguments in line with the prototype documentation +* Perhaps there might be a way to redesign `env.py` such that it can use the data functions, encouraging code reuse ### More elegantly handle when a prototype has no valid members (like `LinkedBelt`) diff --git a/changelog.md b/changelog.md index 09bf2d8..9271cda 100644 --- a/changelog.md +++ b/changelog.md @@ -39,6 +39,7 @@ * Added `unknown` keyword to all entity/tile creation constructs which allows the user to specify what should happen when draftsman encounters an entity it doesn't recognize * Changed `InvalidEntityError` and `InvalidTileErrors` so that they now try to detect a similar tile/entity name and display that information to the user in the error message * For example, if you accidentally type `Container("wodenchest")`, it will realize you probably meant to type `Container("wooden-chest")` and suggest that to you instead +* Renamed `instruments.index` to `instruments.index_of` and `instruments.names` to `instruments.name_of` to reduce confusion with local names * Added a bunch of new documentation to document the above * Added a bunch of new examples to test out the above new features * Added a fixture that ensures that Draftsman is running a vanilla configuration before running tests, and exits if it detects that it is not the case. diff --git a/docs/discusssion/2.0.0_release/2.0.0_release.md b/docs/discusssion/2.0.0_release/2.0.0_release.md index bfb7c33..25a64e8 100644 --- a/docs/discusssion/2.0.0_release/2.0.0_release.md +++ b/docs/discusssion/2.0.0_release/2.0.0_release.md @@ -261,43 +261,364 @@ What about when creating entities? Does creating a new entity from scratch guara A: No, validate will always have to be called at some point afterwards to ensure that the arguments passed to it were correct. It has a certain level of confidence over it's own structure though; maybe if no arguments are passed then it can consider itself valid? (strange circumstance though, and almost never used. Unlikely to be desirable) -## Deferred validation +# 2.0 Release -* Benefits (many!) -* Usage - * For "manual" control with guaranteed no validation: `entity["item"] = ...` - * For concise syntax with user configurable validation: `entity.item = ...` - * For setting with shorthands and guaranteed validation: `entity.set_item(...)` +The development of Draftsman might have seemed to slow over the past few months, but this is not quite true. Instead, I've been spending the last half a year or so working on the next revision of the module which attempts to address the most fundamental problems with it. This has been no trivial matter, and what started as a simple set of minor updates ballooned into a massive major update which spans all of the current remaining issues with Draftsman, and hopefully leaves it in a better place going forward. This document gives an overview of what exactly I perceive these issues to be, as well as their new solutions in Draftsman 2.0. -* Many attributes are now calculated -* Breaking changes +## Deferred Validation + +### The Problem + +A big problem with Draftsman 1.0 is that it doesn't provide any real provisions for users working with blueprint configurations that Draftsman has no knowledge of. For example, consider the following case where we try to import a modded entity under a vanilla Factorio configuration: + +```py +my_modded_blueprint = { + "blueprint": { + "item": "blueprint", + "entities": [ + { + "name": "my-modded-entity", + "position": {"x": 0.5, "y": 0.5} + } + ] + } +} + +blueprint = Blueprint(my_modded_blueprint) # InvalidEntityError: 'my-modded-entity' +``` + +Draftsman complains because it doesn't recognize the entity, and thus cannot meaningfully check it's correctness. From Draftsman's perspective, this makes sense; what's the dimension of this entity? What does it collide with? Is it circuit connectable? What attributes does it have? What are their allowed values? Because Draftsman cannot validate this object to the caliber of any known entity, it decides to consider it a catastrophic error and defer to the user to either remove it or update Draftsman's environment. + +This is great for a number of circumstances, such as the case where you thought you were operating in a modded context but actually weren't, or in the case where the entity's name was simply a misspelled version of a known entity, which you likely want to catch as early as possible. + +The problem is that there are other situations where this is too restrictive. A user might very well want to just *ignore* any unknown entities/tiles and simply return a Blueprint with the known ones, which is in fact how Factorio itself behaves. Going further, you might want to actually preserve these modded entities in the created blueprint; you might want to swap the modded entity to a known one which Draftsman can more easily handle, or maybe you don't want to touch the modded stuff at all and simply pass it to the game, asserting that the game will know how to handle it even if Draftsman doesn't. + +Because the members of `draftsman.data` are fully writable, you can add new entries in their corresponding dictionaries or lists to "trick" Draftsman into allowing these cases. Unfortunately, there are no helper methods which actually make this a palatable option. Care must be taken to provide all of the necessary information in the exact correct format Draftsman expects, which is also likely to be inconsistent across Draftsman versions to boot. The only real sanctioned in Draftsman 1.0 for interacting with modded entities is to modify the entire data configuration by running `draftsman-update` (or the corresponding method in `env.py`). This is easy if you're creating the blueprint string yourself with a set of mods that you're actively playing with, but difficult if: + +* You change your mod configuration to something different but still want to load the modded string anyway, +* You receive the blueprint string from an external source which is running a different mod configuration (of which you may have no knowledge what mods were used!) +* You want to keep the script simple, and have it work with any environment configuration so that anyone can simply just run the script, dammit. + +Clearly, this is a design flaw due to a simplification set early on when designing the tool. Since Draftsman runs the data lifecycle to extract validation information and can do so dynamically, I assumed that all of the needed data would be available at the script runtime, when this is not truly the case. As a result, Draftsman 1.0 is essentially a "greedy" validator, requiring comprehensive information about the entire environment before running, which is useful in some settings, but not in others. + +Another related flaw about Draftsman 1.0 is that even if you want Draftsman to panic, you cannot tell it *when* to do so. Suppose for example that we want to swap all instances of a particular modded entity from a blueprint, but we still want to error if we detect any *other* modded entities. We would like to then write something similar to this: + +```py +modded_string = "..." +blueprint = Blueprint(modded_string, validate=False) + +for i, entity in blueprint.entities: + if entity.name == "modded-lamp": + blueprint.entities[i] = Lamp("small-lamp", position=entity.position) + +blueprint.validate() # InvalidEntityError: 'my-modded-entity' +``` + +... but this is also impossible in Draftsman 1.0. Validation of the `Blueprint` always happens at construction, and cannot be deferred until later; the only other option is to modify the data going into `Blueprint` before constructing it, but this would be more verbose since we're working with dict keys, and we wouldn't have access to all of the nice helper methods that `Blueprint` already provides. + +Finally, a user may also desire more control of the manner and types of warnings/errors which are issued. Some users might want to check just the format of the input data so that no fields have the incorrect type; others might want a comprehensive analysis of all of the field values, to check for redundancies or conceptual faults. You might want to treat errors as warnings, or warnings as errors, or ignore validation completely. What validation should do is more than a simple "yes" or "no", and so a big goal for 2.0 was to allow users to configure it specifically to their desires. + +### The Solution + +As a result, in **Draftsman 2.0** all Draftsman objects now have a `validate()` function which can be used to check their contents at any point after they're created. The function takes a `ValidationMode` parameter, which is an enum which indicates the strictness of the validation, which controls the type and quantity of errors and warnings: + +* `NONE`: No validation whatsoever. Every attribute remains exactly as it was; even values in a known shorthand format are not converted. Impossible to know whether or not this object will import into Factorio when using this mode. This tells Draftsman to simply treat every object verbatim. +* `MINIMUM`: Only returns formatting errors, where data members are of incorrect types. For example, if you set the name of an entity to an integer, this would raise a `DataFormatError`. Besides this, no other warnings or errors are issued. This tells Draftsman to error if the object is in a form that it absolutely knows will NOT import into Factorio. +* `STRICT`: This is the default mode, most closely related to the behavior of Draftsman 1.0. It returns all above errors, as well as most of the errors and warnings most users of the module will be familiar with, in addition to a few new ones. For example, if Draftsman now encounters an entity it doesn't recognize, it issues a `UnknownEntityWarning` instead of an `InvalidEntityError`; Draftsman doesn't know about this entity, but it *may* import into Factorio if the game knows about it. +* `PEDANTIC`: Issues all above errors and warnings, as well as providing more linting-like behavior as well. A good example is setting the limiting bar of a container beyond it's total inventory slots; this creates no issue when importing into the game, and the container behaves as if the bar was set at that point; but it might indicate a conceptual failure from the programmers perspective, and as such it will raise a `BarWarning` if detected under this validation mode. + +Instead of raising the errors and warnings in place, `validate()` returns a wrapper object called a `ValidationResult`. This object contains an `error_list` and a `warning_list` attribute, which can be read, modified, iterated over, saved for later, or any combination thereof. The following snippet simply reissues any detected errors or warnings found with a blueprint: + +```py +blueprint = Blueprint() + +# Modify the blueprint in some way... + +result = blueprint.validate(mode=ValidationMode.STRICT) +for error in result.error_list: + raise error +for warning in result.warning_list: + warnings.warn(warning) +``` + +Because the above pneumonic is likely to appear a lot, it's implemented as a helper method called `reissue_all()`: + +```py +blueprint = Blueprint() + +blueprint.validate(mode=ValidationMode.STRICT).reissue_all() # Identical to the above code +``` + +Creating a `ValidationResult` object also makes it very easy to add other helper methods like `reissue_all()` as well as additional functionality later on without breaking code in written in earlier versions of 2.0. + +Similar to their prior behavior, all `Blueprintable` and `Entity` subclasses still support validation during construction, with the benefit of being able to configure exactly how using the new keyword argument `validate`: + +```py +messed_up_data = { + "name": "unknown", # Should raise a warning + "tags": "incorect" # Should raise an error +} + +container = Container(**messed_up_data, validate=ValidationMode.NONE) # No issues! +assert container.name == "unknown" +assert container.tags == "incorrect" +assert container.to_dict() == { # Even serialization still works + "name": "unknown", + "tags": "incorect" +} + +# Now validate it +result = container.validate() +assert len(result.error_list) == 1 +``` + +In addition to the `validate` parameter, both `Blueprintable` and `Entity` subclasses also have a `validate_assignment` parameter, which configures whether or not to run validation when assigning an attribute of the object: + +```py +container = Container(validate_assignment=ValidationMode.STRICT) + +container.name = TypeError # Raises an error because of type mismatch +container.name = "unknown" # Raises a warning because it's not recognized +container.name = "electric-furnace" # Raises a warning because it's not a Container + +# `validate_assignment` can be set at any point in the objects lifetime +container.validate_assignment = ValidationMode.NONE + +container.name = TypeError # Nothing +container.name = "unknown" # Nothing +container.name = "electric-furnace" # Nothing + +# `validate_assignment` is a per-instance attribute, so individual entities can have their own validation severity +container2 = Container(validate_assignment=ValidationMode.STRICT) + +assert container.validate_assignment is not container2.validate_assignment +``` + +In an effort to provide more flexibility while still keeping the API consistent across many different functions and attributes, Draftsman now has 3 "categories" of manipulating objects for all the validatable types: + +1. Dict-like modification, such as `entity["member"] = ...`; This mode is guaranteed to not run validation ever, regardless of the value of `validate_assignment`. As a consequence, this method is also guaranteed to be computationally cheap. +2. Attribute access, such as `entity.member = ...`; The behavior of this mode is configurable, depending on the value of `validate_assignment`. Usually the most terse syntax. +3. Helper function, such as `entity.set_member(...)`; This mode is guaranteed to run validation always, regardless of the value of `validate_assignment`. Also potentially provides additional functionality, such as setting defaults or formatting complex structures such as conditions, connections, etc. + +In Draftsman 1.0, helper methods were used primarily for either setting attributes via shorthand, or for setting complex structures more easily. They retain these functions in 2.0, but now shorthands of all types can be used with the attribute syntax as well. Note however that shorthand resolution is tied to validation, so if validation is disabled in an entity's `validate_assignment`, it won't be able to handle shorthand formats automatically. + +Finally, in addition to `validate` and `validate_assignment`, `EntityCollection` and `TileCollection` subclasses now also have an `if_unknown` string parameter which indicates what should happen if an unknown entity or tile is appended to a blueprint. To illustrate this more clearly, let's take a look at `draftsman.entity.new_entity()` which also supports this keyword: + +```py +from draftsman.error import InvalidEntityError, DataFormatError +from draftsman.entity import Entity, new_entity + +# Regular, known entities work as you would expect without issue +container = new_entity("wooden-chest") +assert container.type == "container" + +# By default, `if_unknown` is "error", which raises an exception +# (a la Draftsman's current behavior) +try: + error_result = new_entity("unknown", if_unknown="error") +except InvalidEntityError: + pass + +# Setting to "ignore" simply returns nothing if unrecognized +omitted_result = new_entity("unknown", if_unknown="ignore") +assert omitted_result is None + +# "accept" is the final option, and is the most interesting +result = new_entity("unknown", position=(0.5, 0.5), if_unknown="accept") + +# Result in this case is actually an instance of the base `Entity` parent class +assert isinstance(result, Entity) + +# As a result, all of the parameters known to Entity are available +result.position += (10, 10) +result.tags = {"extra": "Tons of extra information we can use."} + +# In addition, we can even add new parameters to this entity and Draftsman won't complain, +# which is different from a known container +result["extra_parameter"] = "amazing!" + +# We can then serialize this object with the new keys intact +assert result.to_dict() == { + "name": "unknown", + "position": {"x": 10.5, "y": 10.5}, + "tags": {"extra": "Tons of extra information we can use."}, + "extra_parameter": "amazing!" +} + +# And we can even still get validation for the few attributes we do know about! +try: + result.tags = "incorrect" # DataFormatError: tags must be a dict +except DataFormatError: + pass +``` + +And finally, for those who just want to update Draftsman's data on the fly, there are now helper methods for all the class types in `draftsman.data` which allow you to add new or modify existing data: + +```py +from draftsman.data import entities +from draftsman.entity import Container + +entities.add_entity( + name="new-container", + entity_type="container", + collision_box=((-0.4, -0.4), (0.4, 0.4)), + inventory_size=100 + # Any other relevant keyword arguments can be provided and will be added to + # the raw data entry +) + +# "new-container" is now in all the correct places +assert "new-container" in entities.raw +assert "new-container" in entities.containers +# (NOTE: sort order is not currently preserved when adding at runtime) +# (This is harder than it sounds so I'm posteponing this until later) + +container = Container("new-container") +assert (container.tile_width, container.tile_height) == (1, 1) +assert container.position == Vector(0.5, 0.5) +assert container.inventory_size == 100 + +# "new-container" will persist until the script ends +``` + +These methods are provided as a way to allow Draftsman to remain maximally strict against unknown data, but permit the user to quickly update said data just for the scope of a single script. This is provided mainly as a stopgap for cases where only a few entities/tiles are needed, which may be faster and/or simpler than grabbing the mod files themselves and running `draftsman-update`. + +Hopefully these new features will allow users of 2.0 to have much more control of the manner in which their structures are validated, (hopefully) firmly crossing this one off the TODO list. ## Validation is now done with `pydantic` instead of `schema` -* Performance improvements - * Pydantic's backend is in rust, which is nice - * In addition, all entities and blueprintables have been made memory lean, seriously improving performance just from that alone -* Schemas are now defined in a more paletable format, hopefully reducing maintenence cost -* All warnings and errors can be integrated into one system, so not only will validation be easier it will likely be more consistent -* `to_dict()` can now optionally strip defaults and `None` - * Blueprintable `to_string()` still always excludes defaults and `None` for maximum compression -* Finally, we can convert the `BaseModel` object of any defined model into a compliant JSON schema specification, which you could theoretically export to work with any program; all fields are given descriptions to aid in this +All of this new magic is facilitated with the [Pydantic](TODO) validation library. I went through a number of different libraries before I finally settled on Pydantic; the primary reasons were: -## Factorio StdLib integration +* Pydantic schemas are defined using Python type hints instead of a custom internal language specific to the library, making it easier for other maintainers to contribute to the project and generally make the whole validation code much easier on the eyes. +* Pydantic allows to specify custom validation functions which can be run at basically any point during the validation step, before, after, during, and can even stop validation altogether halfway through. With conditional enabling/disabling of these functions, it should also be possible to make version specific checks to allow for not only consistent validation, but also validation specific to a particular Factorio version. Perhaps even users themselves could add their own custom validation functions. Even if I don't stick with Pydantic ultimately (it has some problems I've yet to resolve), I firmly believe any future solution will need to have something like this in place. +* Warnings and errors are all integrated into one system, instead of having split and competing ones. In addition, *all* validation uses the same backend, which means that not only does `entity.items = {...}` issue the same warnings as `entity.set_items(...)`, but they both use the exact same code which is defined in one place. +* Pydantic supports JSON schema generation, meaning that you can take any Pydantic model and output an accurate JSON dictionary which describes it's exact format. This can then be extracted and used with *any* compliant JSON schema library to validate inputs of any Draftsman model. Furthermore, with a little bit of help it's highly likely that you could make a human readable digest out of this information, and all fields are given descriptions to help aid in this. -* Direction and Orientation get their own classes, which support operators like `next()`, `opposite()`, `to_vector()`, etc. - * Don't worry: raw floats and ints also map to these as well, so no breakage here -* New enums for almost every literal string; it will be more verbose, but better in every other way - * That being said, string literals are still supported as valid argument values (which are converted to enums internally), so for simple scripts you can maintain the shorthand pattern as before -* New enum for ticks as well, to make specifying time values just a little easier. In addition, you can use the `Ticks.from_timedelta(td)` to convert a `dateutil.timedelta` object into a set of ticks, in case you're measuring time that way +Additionally, the backend of Pydantic is written in Rust, which theoretically might lead to a considerable performance improvement, which was another longstanding issue with Draftsman. Some other ground has been made on this front; simply by reducing the memory of many Draftsman constructs alone has greatly improved the performance on very large blueprintable objects. And, since you can defer or omit validation, it's also theoretically possible to create a script in "slow" mode with validation on at every step, and then selectively turn off validation wherever it's known that the data will always be valid, potentially saving a lot of wall time. Of course, rigorous and concrete benchmarks are still yet to be made, so these performance gains are currently speculation; but I do believe making the library more performant going forward is likely to be much easier. -## TrainSchedules properly supported +However, because Pydantic uses type hints to express it's schemas, this means that the new minimum Python version required will be Python 3.7. This also allows Draftsman to use a number of modern Python goodies that were previously precluded from it due to it's backwards compatibility restrictions. For those concerned, Draftsman 1.0 will remain available on PYPI and under the `1.0` branch on Github for anyone still on an older version of Python and can't update, but all new features will likely only exist on the main (2.0) branch going forward. -## RailPlanner class (finally) +## `RailPlanner`, `Schedule`, `WaitConditions`, and `TrainConfiguration` (finally) -## Upgrades to data +Another longstanding weak point of Draftsman was it's rudimentary API when interacting with rails, trains, and their schedules. In 2.0, this area has seen large improvements. + +For placing rails, the long in-development `RailPlanner` class is now feature functional. It allows you to draw rail paths using turtle-like commands, entirely similar to how the game itself does it: + +```py +from draftsman.blueprintable import Blueprint +from draftsman.rail import RailPlanner # New access point for rail related classes + +# Create a new RailPlanner +# The name here refers to the vanilla rails, but you can change this to work with +# modded rails as well +planner = RailPlanner(name="rail") + +# RailPlanners have a head position and direction, and can move forward, left, or right +planner.head_position = (0, 0) +planner.head_direction = Direction.SOUTH +planner.move_forward(5) # Place 5 straight rails southward +planner.turn_right() # Turn 45 degrees to the right +planner.turn_left(3) # Turn 135 degrees to the left, so we're now facing East +planner.move_forward(10) + +# The head can be moved and oriented at any point +planner.head_position = (10, 10) +planner.head_direction = Direction.EAST +planner.move_forward(5) + +# RailPlanners can also place rail signals (on either side of the track)... +planner.add_signal(entity="rail-signal") +planner.add_signal(entity="rail-chain-signal", right=False) +planner.move_forward(5) -## Miscellaneous Changes: +# or train stops (on either side of the track) +planner.add_station(entity="train-stop", station="Name of Station") +planner.move_forward(5) + +# Both of the above methods also allow you to pass in an existing entity instance +from draftsman.entity import TrainStop +configured_stop = TrainStop() +configured_stop.station = "Configured Station" +configured_stop.read_from_train = True + +# Adds a station with the parameters from `configured_stop` +planner.add_station(entity=configured_stop) + +# Now we can simply add the planner to a blueprint +blueprint = Blueprint() +blueprint.entities.append(planner) +print(blueprint.to_string()) +``` + +`TrainConfiguration` allows you to specify a sequence of rolling stock in the community-accepted syntax for describing trains: + +```py + +``` + +For more information on how to use the features of the new classes, see [these examples](TODO) + +## Numerous Additional Quality of Life Features + +2.0 also provides a number of straightforward improvements over 1.0: + +1. `Direction` and `Orientation` are now more than just normal enumerations, and support all of the methods supported with the Factorio Standard Library: + +```py +>>> from draftsman.constants import Direction, Orientation +>>> Direction.NORTH.opposite() + +>>> Direction.NORTH.next() + +>>> Direction.NORTH.previous(eight_way=True) + +>>> Direction.NORTH.to_vector() +(0, -1) +>>> Direction.NORTH.to_orientation() + +>>> Orientation.NORTH.to_direction() + +>>> Orientation.NORTH.to_vector(magnitude=10) +(0, -10) +``` + +The `Ticks` constant class has also been added, which should make translating time-based periods much easier: + +```py +from draftsman.constants import Ticks +from datetime import datetime + +assert Ticks.SECOND == 60 +assert 5 * Ticks.SECOND == 300 + +# You can even convert `timedeltas` to ticks +t1 = datetime.strptime("10:15:04", "%H:%M:%S") +t2 = datetime.strptime("10:19:27", "%H:%M:%S") +td = t2 - t1 +print(Ticks.from_timedelta(td)) # 15780 +``` * `position` and `tile_position` now properly update the other when modifying by attribute `x` or `y`; Example: * You can now manually specify the `index` of a Blueprintable in a blueprint book manually instead of rearranging the blueprint's list. This allows not only for flexibility, but it also now allows you to specify blueprint books with non-contiguous blueprint entries, which you were previously unable to do in `1.0`. +* A few more example scripts in the `examples` folder, but more excitingly examples are now organized into category directories, and a README.md is provided in each directory which gives an overview of each example. +* `to_dict()` for all functions now have two parameters, `exclude_none` and `exclude_default`. They default to `True`, but can be turned off if you want to ensure that every supplied key makes it out for data consistency reasons. For those worried about blueprint string size, fear not; `to_string()` still uses the minimum format possible, and may even be smaller now than in 1.0. +* Documentation has all been updated to support 2.0, and a new branch on ReadTheDocs has been made to differentiate between 1.0 and 2.0 + + + +## Upgrades to data + +## Reduced memory footprint + +* Almost all information known to the class is now at the class level + - Before, things like the `rotatable` entity flag were kept as members in each entity instance, even though it's the same for all instances of a particular entity. This is now fixed. +* Lots of parameters are now calculated as needed instead of stored + - This has the benefit of reducing memory footprint further as well as only performing the calculation when the user actually needs it + - If the user doesn't want to recalculate it every time, they can choose to cache it at their own discretion + +## Future work + +* `is_valid` attribute for validation caching + - This would be very nice, but it is also very complex with the current structure of the project + +the list of things TODO has now moved to a dedicated document, with more information about each component. If you're interested in contributing, it should now be easier to see my intentions for the project going forward. \ No newline at end of file diff --git a/draftsman/classes/association.py b/draftsman/classes/association.py index 6f8392b..5741f57 100644 --- a/draftsman/classes/association.py +++ b/draftsman/classes/association.py @@ -1,8 +1,10 @@ # association.py -import weakref +from pydantic import Field, WrapValidator +from pydantic_core import core_schema -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Annotated, Any +import weakref if TYPE_CHECKING: # pragma: no coverage from draftsman.classes.entity import Entity @@ -16,6 +18,16 @@ class Association(weakref.ref): creating blueprints, and better visual representation. """ + Format = Annotated[ + int, + Field(ge=0, lt=2**64), + WrapValidator( + lambda value, handler: value + if isinstance(value, Association) + else handler(value) + ), + ] + def __init__(self, entity: "Entity"): super(Association, self).__init__(entity) @@ -63,3 +75,7 @@ def __repr__(self) -> str: # pragma: no coverage " '{}'".format(self().id) if self().id is not None else "", id(self()), ) + + # @classmethod + # def __get_pydantic_core_schema__(cls, _): + # return core_schema.int_schema() diff --git a/draftsman/classes/blueprint.py b/draftsman/classes/blueprint.py index e9e1f29..ef1b61e 100644 --- a/draftsman/classes/blueprint.py +++ b/draftsman/classes/blueprint.py @@ -103,7 +103,7 @@ Color, Connections, DraftsmanBaseModel, - Icons, + Icon, IntPosition, uint16, uint64, @@ -111,12 +111,19 @@ from draftsman.entity import Entity from draftsman.tile import Tile from draftsman.classes.schedule import Schedule -from draftsman.utils import AABB, aabb_to_dimensions, encode_version, extend_aabb, flatten_entities, reissue_warnings +from draftsman.utils import ( + AABB, + aabb_to_dimensions, + encode_version, + extend_aabb, + flatten_entities, + reissue_warnings, +) from draftsman.warning import DraftsmanWarning from builtins import int import copy -from typing import Any, Literal, Optional, Sequence, Union +from typing import Any, Literal, Optional, Sequence, TypedDict, Union import warnings from pydantic import ( ConfigDict, @@ -167,8 +174,8 @@ def _throw_invalid_association(entity): connection_points = connections[side][color] for point in connection_points: old = point["entity_id"] - if isinstance(old, int): - continue + # if isinstance(old, int): + # continue if old() is None: # pragma: no coverage _throw_invalid_association(entity) else: # Association @@ -178,8 +185,8 @@ def _throw_invalid_association(entity): connection_points = connections[side] for point in connection_points: old = point["entity_id"] - if isinstance(old, int): - continue + # if isinstance(old, int): + # continue if old() is None: # pragma: no coverage _throw_invalid_association(entity) else: # Association @@ -274,15 +281,15 @@ class BlueprintObject(DraftsmanBaseModel): description=""" The color to draw the label of this blueprint with, if 'label' is present. Defaults to white if omitted. - """ + """, ) description: Optional[str] = Field( None, description=""" A string description given to this Blueprint. - """ + """, ) - icons: Optional[Icons] = Field( + icons: Optional[list[Icon]] = Field( None, description=""" A set of signal pictures to associate with this Blueprint. @@ -301,61 +308,61 @@ class BlueprintObject(DraftsmanBaseModel): ) snap_to_grid: Optional[IntPosition] = Field( - IntPosition(x=1, y=1), + IntPosition(x=1, y=1), alias="snap-to-grid", description=""" The dimension of a square grid to snap this blueprint to, if present. - """ + """, ) absolute_snapping: Optional[bool] = Field( - True, + True, alias="absolute-snapping", description=""" Whether or not 'snap-to-grid' is relative to the global map coordinates, or to the position of the first blueprint built. - """ + """, ) # snapping_grid_position: Optional[IntPosition] = Field(None, exclude=True) # TODO: remove position_relative_to_grid: Optional[IntPosition] = Field( - IntPosition(x=0, y=0), + IntPosition(x=0, y=0), alias="position-relative-to-grid", description=""" Any positional offset that the snapping grid has if 'absolute-snapping' is true. - """ + """, ) - entities: Optional[list[dict]] = Field( # TODO + entities: Optional[list[dict]] = Field( # TODO [], description=""" The list of all entities contained in the blueprint. - """ + """, ) - tiles: Optional[list[dict]] = Field( # TODO + tiles: Optional[list[dict]] = Field( # TODO [], description=""" The list of all tiles contained in the blueprint. - """ + """, ) - schedules: Optional[list[dict]] = Field( # TODO + schedules: Optional[list[dict]] = Field( # TODO [], description=""" The list of all schedules contained in the blueprint. - """ + """, ) @field_validator("version", mode="before") @classmethod def normalize_to_int(cls, value: Any): - if isinstance(value, Sequence): + if isinstance(value, Sequence) and not isinstance(value, str): return encode_version(*value) return value @field_serializer("snap_to_grid", when_used="unless-none") def serialize_snapping_grid(self, _): return self._snap_to_grid.to_dict() - + @field_serializer("position_relative_to_grid", when_used="unless-none") def serialize_position_relative(self, _): return self._position_relative_to_grid.to_dict() @@ -388,11 +395,11 @@ def serialize_position_relative(self, _): blueprint: BlueprintObject index: Optional[uint16] = Field( - None, + None, description=""" The index of the blueprint inside a parent BlueprintBook's blueprint list. Only meaningful when this object is inside a BlueprintBook. - """ + """, ) model_config = ConfigDict(title="Blueprint") @@ -403,8 +410,8 @@ def serialize_position_relative(self, _): @reissue_warnings def __init__( - self, - blueprint: Union[str, dict]=None, + self, + blueprint: Union[str, dict] = None, index: uint16 = None, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -432,21 +439,20 @@ def __init__( index=index, entities=[], tiles=[], - schedules=[] + schedules=[], ) self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) @reissue_warnings def setup( - self, + self, label: str = None, label_color: Color = None, description: str = None, - icons: Icons = None, + icons: list[Icon] = None, version: uint64 = __factorio_version_info__, snapping_grid_size: Union[Vector, PrimitiveVector, None] = None, snapping_grid_position: Union[Vector, PrimitiveVector, None] = None, @@ -457,7 +463,7 @@ def setup( schedules: Union[ScheduleList, list[Schedule]] = [], index: uint16 = None, **kwargs - ): # TODO: keyword arguments + ): # TODO: keyword arguments self._root.blueprint = Blueprint.Format.BlueprintObject(item="blueprint") @@ -533,7 +539,7 @@ def setup( continue if side in {"1", "2"}: - for color, _ in connections[side]: # TODO fix + for color, _ in connections[side]: # TODO fix connection_points = connections[side][color] if connection_points is None: continue @@ -544,7 +550,7 @@ def setup( elif side in {"Cu0", "Cu1"}: # pragma: no branch connection_points = connections[side] if connection_points is None: - continue + continue # pragma: no coverage for point in connection_points: old = point["entity_id"] - 1 point["entity_id"] = Association(self.entities[old]) @@ -604,7 +610,7 @@ def label_color(self, value: Optional[Color]): self.Format.BlueprintObject, self._root.blueprint, "label_color", - value + value, ) self._root[self._root_item]["label_color"] = result else: @@ -615,9 +621,7 @@ def set_label_color(self, r, g, b, a=None): TODO """ try: - self._root[self._root_item]["label_color"] = Color( - r=r, g=g, b=b, a=a - ) + self._root[self._root_item]["label_color"] = Color(r=r, g=g, b=b, a=a) except ValidationError as exc: raise DataFormatError from exc @@ -662,7 +666,7 @@ def snapping_grid_size(self, value: Union[Vector, PrimitiveVector, None]): # ========================================================================= @property - def snapping_grid_position(self) -> Optional[Vector]: + def snapping_grid_position(self) -> Vector: """ Sets the position of the snapping grid. Offsets all of the positions of the entities by this amount, effectively acting as a @@ -727,7 +731,7 @@ def absolute_snapping(self, value: Optional[bool]): self.Format.BlueprintObject, self._root.blueprint, "absolute_snapping", - value + value, ) self._root[self._root_item]["absolute_snapping"] = result else: @@ -761,9 +765,13 @@ def position_relative_to_grid(self, value: Union[Vector, PrimitiveVector, None]) # else: # self._root[self._root_item]["position_relative_to_grid"] = value if value is None: - self._root.blueprint._position_relative_to_grid.update_from_other((0, 0), int) + self._root.blueprint._position_relative_to_grid.update_from_other( + (0, 0), int + ) else: - self._root.blueprint._position_relative_to_grid.update_from_other(value, int) + self._root.blueprint._position_relative_to_grid.update_from_other( + value, int + ) # ========================================================================= @@ -802,7 +810,9 @@ def entities(self, value: Union[EntityList, list[EntityLike]]): self.recalculate_area() - def on_entity_insert(self, entitylike: EntityLike, merge: bool) -> Optional[EntityLike]: + def on_entity_insert( + self, entitylike: EntityLike, merge: bool + ) -> Optional[EntityLike]: """ Callback function for when an :py:class:`.EntityLike` is added to this Blueprint's :py:attr:`entities` list. Handles the addition of the entity @@ -924,14 +934,11 @@ def tiles(self, value: Union[TileList, list[Tile]]): self.tile_map.clear() if value is None: - # self._root[self._root_item]["tiles"] = TileList(self) - self._root._tiles = TileList(self) # TODO: implement clear - elif isinstance(value, list): - # self._root[self._root_item]["tiles"] = TileList(self, value) - self._root._tiles = TileList(self, value) + self._root._tiles = TileList(self) # TODO: implement clear elif isinstance(value, TileList): - # self._root[self._root_item]["tiles"] = TileList(self, value.data) self._root._tiles = TileList(self, value.data) + elif isinstance(value, list): + self._root._tiles = TileList(self, value) else: raise TypeError("'tiles' must be a TileList, list, or None") @@ -963,6 +970,7 @@ def on_tile_insert(self, tile: Tile, merge: bool) -> Optional[Tile]: ) = aabb_to_dimensions(self.area) # Check the blueprint for unreasonable size + # TODO: remove and move elsewhere if self._tile_width > 10000 or self._tile_height > 10000: raise UnreasonablySizedBlueprintError( "Current blueprint dimensions ({}, {}) exceeds the maximum size" @@ -1035,7 +1043,7 @@ def schedules(self, value: Union[ScheduleList, list[Schedule]]): # wipe the locomotives of each schedule when doing so if value is None: # self._root[self._root_item]["schedules"] = ScheduleList() - self._root._schedules = ScheduleList() # TODO: clear + self._root._schedules = ScheduleList() # TODO: clear elif isinstance(value, ScheduleList): # self._root[self._root_item]["schedules"] = value self._root._schedules = value @@ -1046,8 +1054,7 @@ def schedules(self, value: Union[ScheduleList, list[Schedule]]): # ========================================================================= @property - def area(self) -> AABB: - # type: () -> list[list[float]] + def area(self) -> Optional[AABB]: """ The Axis-aligned Bounding Box of the Blueprint's dimensions. Not exported; for user aid. Read only. @@ -1105,33 +1112,27 @@ def double_grid_aligned(self) -> bool: # ========================================================================= - @property - def index(self) -> Optional[uint16]: - """ - The index of the blueprint in a parent :py:class:`BlueprintBook`. Index - is automatically generated if omitted, but can be manually set with this - attribute. ``index`` has no meaning when the Blueprint is not located in - a BlueprintBook. - - :getter: Gets the index of the blueprint, or ``None`` if not set. - :setter: Sets the index of the blueprint, or removes it if set to ``None``. - :type: ``int`` - """ - return self._root.index - - @index.setter - def index(self, value: Optional[uint16]): - if self.validate_assignment: - result = attempt_and_reissue( - self, - self.Format, - self._root, - "index", - value - ) - self._root.index = result - else: - self._root.index = value + # @property + # def index(self) -> Optional[uint16]: + # """ + # The index of the blueprint in a parent :py:class:`BlueprintBook`. Index + # is automatically generated if omitted, but can be manually set with this + # attribute. ``index`` has no meaning when the Blueprint is not located in + # a BlueprintBook. + + # :getter: Gets the index of the blueprint, or ``None`` if not set. + # :setter: Sets the index of the blueprint, or removes it if set to ``None``. + # :type: ``int`` + # """ + # return self._root.index + + # @index.setter + # def index(self, value: Optional[uint16]): + # if self.validate_assignment: + # result = attempt_and_reissue(self, self.Format, self._root, "index", value) + # self._root.index = result + # else: + # self._root.index = value # ========================================================================= # Utility functions @@ -1169,7 +1170,7 @@ def validate( if mode is ValidationMode.NONE or (self.is_valid and not force): return output - context = { + context: dict[str, Any] = { "mode": mode, "object": self, "warning_list": [], @@ -1177,34 +1178,30 @@ def validate( } try: - print(self._root) + # print(self._root) result = self.Format.model_validate( - self._root, - strict=False, # TODO: ideally this should be strict - context=context + self._root, + strict=False, # TODO: ideally this should be strict + context=context, ) - print(result) + # print(result) # Reassign private attributes result._entities = self._root._entities result._tiles = self._root._tiles result._schedules = self._root._schedules result.blueprint._snap_to_grid = self._root.blueprint._snap_to_grid - result.blueprint._snapping_grid_position = self._root.blueprint._snapping_grid_position - result.blueprint._position_relative_to_grid = self._root.blueprint._position_relative_to_grid + result.blueprint._snapping_grid_position = ( + self._root.blueprint._snapping_grid_position + ) + result.blueprint._position_relative_to_grid = ( + self._root.blueprint._position_relative_to_grid + ) # Acquire the newly converted data self._root = result except ValidationError as e: output.error_list.append(DataFormatError(e)) - if mode is ValidationMode.MINIMUM: - return output - - if mode is ValidationMode.PEDANTIC: - warning_list = output.error_list - else: - warning_list = output.warning_list - - warning_list += context["warning_list"] + output.warning_list += context["warning_list"] # if len(output.error_list) == 0: # # Set the `is_valid` attribute @@ -1214,13 +1211,7 @@ def validate( return output - def inspect(self): - """ - TODO - """ - pass - - def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: + def to_dict(self, exclude_none: bool = True, exclude_defaults: bool = True) -> dict: # Create a copy of root, since we don't want to clobber the original # data when creating a dict representation # We skip copying the special lists because we have to handle their @@ -1235,7 +1226,9 @@ def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: # if self.index is not None: # root_copy["index"] = self.index - result = super().to_dict(exclude_none=exclude_none, exclude_defaults=exclude_defaults) + result = super().to_dict( + exclude_none=exclude_none, exclude_defaults=exclude_defaults + ) # We then convert all the entities, tiles, and schedules to # 1-dimensional lists, flattening any Groups that this blueprint @@ -1263,16 +1256,16 @@ def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: # ) # Make sure that snapping_grid_position is respected - if self.snapping_grid_position is not None: - # Offset Entities - for entity in result["blueprint"]["entities"]: - entity["position"]["x"] -= self.snapping_grid_position.x - entity["position"]["y"] -= self.snapping_grid_position.y + # if self.snapping_grid_position is not None: + # Offset Entities + for entity in result["blueprint"]["entities"]: + entity["position"]["x"] -= self.snapping_grid_position.x + entity["position"]["y"] -= self.snapping_grid_position.y - # Offset Tiles - for tile in result["blueprint"]["tiles"]: - tile["position"]["x"] -= self.snapping_grid_position.x - tile["position"]["y"] -= self.snapping_grid_position.y + # Offset Tiles + for tile in result["blueprint"]["tiles"]: + tile["position"]["x"] -= self.snapping_grid_position.x + tile["position"]["y"] -= self.snapping_grid_position.y # # We then create an output dict # out_dict = out_model.model_dump( @@ -1282,17 +1275,21 @@ def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: # warnings=False, # until `model_construct` is properly recursive # ) - print(result) - print(self.snapping_grid_size) - print(self.position_relative_to_grid) + # print(result) + # print(self.snapping_grid_size) + # print(self.position_relative_to_grid) - if "snap-to-grid" in result["blueprint"] and result["blueprint"]["snap-to-grid"] == {"x": 0, "y": 0}: + if "snap-to-grid" in result["blueprint"] and result["blueprint"][ + "snap-to-grid" + ] == {"x": 0, "y": 0}: del result["blueprint"]["snap-to-grid"] - if "position-relative-to-grid" in result["blueprint"] and result["blueprint"]["position-relative-to-grid"] == {"x": 0, "y": 0}: + if "position-relative-to-grid" in result["blueprint"] and result["blueprint"][ + "position-relative-to-grid" + ] == {"x": 0, "y": 0}: del result["blueprint"]["position-relative-to-grid"] if len(result["blueprint"]["entities"]) == 0: - del result["blueprint"]['entities'] + del result["blueprint"]["entities"] if len(result["blueprint"]["tiles"]) == 0: del result["blueprint"]["tiles"] if len(result["blueprint"]["schedules"]) == 0: @@ -1302,9 +1299,9 @@ def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: # ========================================================================= - def __eq__(self, other: "Blueprint") -> bool: + def __eq__(self, other: Any) -> bool: if not isinstance(other, Blueprint): - return False + return NotImplemented return ( self.label == other.label diff --git a/draftsman/classes/blueprint_book.py b/draftsman/classes/blueprint_book.py index 5862dfd..275a5df 100644 --- a/draftsman/classes/blueprint_book.py +++ b/draftsman/classes/blueprint_book.py @@ -66,19 +66,24 @@ from draftsman.classes.upgrade_planner import UpgradePlanner from draftsman.constants import ValidationMode from draftsman.error import DataFormatError -from draftsman.signatures import Color, DraftsmanBaseModel, Icons, uint16 +from draftsman.signatures import Color, DraftsmanBaseModel, Icon, uint16, uint64 from draftsman.utils import encode_version, reissue_warnings from draftsman.warning import DraftsmanWarning, IndexWarning from builtins import int -from pydantic import ConfigDict, Field, PrivateAttr, field_validator, ValidationError -from typing import Literal, Optional, Union +from pydantic import ( + ConfigDict, + Field, + PrivateAttr, + ValidatorFunctionWrapHandler, + ValidationInfo, + field_validator, + ValidationError, +) +from typing import Any, Literal, Optional, Sequence, Union import warnings -try: # pragma: no coverage - from collections.abc import MutableSequence -except ImportError: # pragma: no coverage - from collections import MutableSequence +from collections.abc import MutableSequence class BlueprintableList(MutableSequence): @@ -88,44 +93,42 @@ class BlueprintableList(MutableSequence): can exist inside other BlueprintBook instances. """ - def __init__(self, initlist=None): - # type: (list[Blueprint], str) -> None + def __init__(self, initlist: list[Union[dict, Blueprintable]]=[]): self.data: list[Blueprintable] = [] - if initlist is not None: - for elem in initlist: - if isinstance(elem, dict): - # fmt: off - if "blueprint" in elem: - self.append( - Blueprint( - elem, - ) + for elem in initlist: + if isinstance(elem, dict): + # fmt: off + if "blueprint" in elem: + self.append( + Blueprint( + elem, ) - elif "deconstruction_planner" in elem: - self.append( - DeconstructionPlanner( - elem, - ) - ) - elif "upgrade_planner" in elem: - self.append( - UpgradePlanner( - elem, - ) + ) + elif "deconstruction_planner" in elem: + self.append( + DeconstructionPlanner( + elem, ) - elif "blueprint_book" in elem: - self.append( - BlueprintBook( - elem, - ) + ) + elif "upgrade_planner" in elem: + self.append( + UpgradePlanner( + elem, ) - else: - raise DataFormatError( - "Dictionary input cannot be resolve to a blueprintable" + ) + elif "blueprint_book" in elem: + self.append( + BlueprintBook( + elem, ) - # fmt: on + ) else: - self.append(elem) + raise DataFormatError( + "Dictionary input cannot be resolve to a blueprintable" + ) + # fmt: on + else: + self.append(elem) def insert(self, idx: int, value: Blueprintable): # Make sure the blueprintable is valid @@ -133,16 +136,15 @@ def insert(self, idx: int, value: Blueprintable): self.data.insert(idx, value) - def __getitem__(self, idx: int) -> Blueprintable: + def __getitem__(self, idx: Union[int, slice]) -> Union[Any, MutableSequence[Any]]: return self.data[idx] - def __setitem__(self, idx: int, value: Blueprintable): - # Make sure the blueprintable is valid + def __setitem__(self, idx: Union[int, slice], value: Any) -> None: self.check_blueprintable(value) self.data[idx] = value - def __delitem__(self, idx: int): + def __delitem__(self, idx: Union[int, slice]) -> None: del self.data[idx] def __len__(self) -> int: @@ -176,13 +178,56 @@ class Format(DraftsmanBaseModel): _blueprints: BlueprintableList = PrivateAttr() class BlueprintBookObject(DraftsmanBaseModel): - item: Literal["blueprint-book"] - label: str | None = None - label_color: Color | None = None - description: str | None = None - icons: Icons | None = None - active_index: int - version: int | None = Field(None, ge=0, lt=2**64) + item: Literal["blueprint-book"] = Field( + ..., + description=""" + The item that this BlueprintBookItem object is associated with. + Always equivalent to 'blueprint-book'. + """, + ) + label: Optional[str] = Field( + None, + description=""" + A string title for this BlueprintBook. + """, + ) + label_color: Optional[Color] = Field( + None, + description=""" + The color to draw the label of this blueprint book with, if + 'label' is present. Defaults to white if omitted. + """, + ) + description: Optional[str] = Field( + None, + description=""" + A string description given to this BlueprintBook. + """, + ) + icons: Optional[list[Icon]] = Field( + None, + description=""" + A set of signal pictures to associate with this BlueprintBook. + """, + ) + active_index: Optional[uint16] = Field( + 0, + description=""" + The numerical index of the currently selected blueprint in the + blueprint book. + """, + ) + version: Optional[uint64] = Field( + None, + description=""" + What version of Factorio this UpgradePlanner was made + in/intended for. Specified as 4 unsigned 16-bit numbers combined, + representing the major version, the minor version, the patch + number, and the internal development version respectively. The + most significant digits correspond to the major version, and the + least to the development number. + """, + ) blueprints: list[ Union[ @@ -193,14 +238,35 @@ class BlueprintBookObject(DraftsmanBaseModel): UpgradePlanner.Format, "BlueprintBook.Format", ] - ] = [] + ] = Field( + [], + description=""" + The list of blueprintable objects enclosed within this blueprint + book. Can be blueprints, deconstruction planners, upgrade + planners or even other blueprint books. If multiple blueprintable + objects share the same 'index', the last one which was added at + that index is imported into the book. + """, + ) + + @field_validator("version", mode="before") + @classmethod + def normalize_to_int(cls, value: Any): + # TODO: improve this + if isinstance(value, Sequence) and not isinstance(value, str): + return encode_version(*value) + return value blueprint_book: BlueprintBookObject - index: uint16 | None = Field( - None, description="Only present when inside a BlueprintBook" + index: Optional[uint16] = Field( + None, + description=""" + The index of the blueprint inside a parent BlueprintBook's blueprint + list. Only meaningful when this object is inside a BlueprintBook. + """, ) - model_config = ConfigDict(title="ExternalObject") + model_config = ConfigDict(title="BlueprintBook") # ========================================================================= # Constructors @@ -208,9 +274,9 @@ class BlueprintBookObject(DraftsmanBaseModel): @reissue_warnings def __init__( - self, - blueprint_book: Union[str, dict]=None, - index: uint16 = None, + self, + blueprint_book: Optional[Union[str, dict]] = None, + index: Optional[uint16] = None, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] ] = ValidationMode.STRICT, @@ -225,7 +291,7 @@ def __init__( :param blueprint_book: Either a Factorio-format blueprint string or a ``dict`` object with the desired keys in the correct format. """ - self._root: __class__.Format + self._root: BlueprintBook.Format super().__init__( root_item="blueprint_book", @@ -234,12 +300,12 @@ def __init__( init_data=blueprint_book, index=index, blueprints=[], + active_index=0, ) self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) @reissue_warnings def setup(self, **kwargs): @@ -263,9 +329,7 @@ def setup(self, **kwargs): # self._root[self._root_item]["blueprints"] = BlueprintableList( # kwargs.pop("blueprints"), unknown=unknown # ) - self._root._blueprints = BlueprintableList( - kwargs.pop("blueprints") - ) + self._root._blueprints = BlueprintableList(kwargs.pop("blueprints")) else: # self._root[self._root_item]["blueprints"] = BlueprintableList() self._root._blueprints = BlueprintableList() @@ -334,17 +398,14 @@ def set_label_color(self, r, g, b, a=None): TODO """ try: - self._root[self._root_item]["label_color"] = Color( - r=r, g=g, b=b, a=a - ) + self._root[self._root_item]["label_color"] = Color(r=r, g=g, b=b, a=a) except ValidationError as e: raise DataFormatError from e # ========================================================================= @property - def active_index(self): - # type: () -> int + def active_index(self) -> Optional[uint16]: """ The currently selected Blueprintable in the BlueprintBook. Zero-indexed, from ``0`` to ``len(blueprint_book.blueprints) - 1``. @@ -361,20 +422,23 @@ def active_index(self): return self._root[self._root_item].get("active_index", None) @active_index.setter - def active_index(self, value): - # type: (int) -> None - if value is None: - self._root[self._root_item]["active_index"] = 0 - elif isinstance(value, int): - self._root[self._root_item]["active_index"] = value + def active_index(self, value: Optional[uint16]): + if self.validate_assignment: + result = attempt_and_reissue( + self, + self._root_format, + self._root[self._root_item], + "active_index", + value, + ) + self._root[self._root_item]["active_index"] = result else: - raise TypeError("'active_index' must be a int or None") + self._root[self._root_item]["active_index"] = value # ========================================================================= @property - def blueprints(self): - # type: () -> BlueprintableList + def blueprints(self) -> BlueprintableList: """ The list of Blueprints or BlueprintBooks contained within this BlueprintBook. @@ -391,7 +455,7 @@ def blueprints(self): return self._root._blueprints @blueprints.setter - def blueprints(self, value): + def blueprints(self, value: Union[list, BlueprintableList, None]): if value is None: # self._root[self._root_item]["blueprints"] = BlueprintableList() self._root._blueprints = BlueprintableList() @@ -401,14 +465,20 @@ def blueprints(self, value): # self._root[self._root_item]["blueprints"] = BlueprintableList(value) self._root._blueprints = BlueprintableList(value) else: - raise DataFormatError("'blueprints' must be a BlueprintableList, a list, or None") + raise DataFormatError( + "'blueprints' must be a BlueprintableList, a list, or None" + ) # ========================================================================= # Utility functions # ========================================================================= def validate( - self, mode: ValidationMode = ValidationMode.STRICT, force: bool = False + self, + mode: Union[ + ValidationMode, Literal["none", "minimum", "strict", "pedantic"] + ] = ValidationMode.STRICT, + force: bool = False ) -> ValidationResult: mode = ValidationMode(mode) @@ -417,7 +487,7 @@ def validate( if mode is ValidationMode.NONE or (self.is_valid and not force): return output - context = { + context: dict[str, Any] = { "mode": mode, "object": self, "warning_list": [], @@ -437,15 +507,13 @@ def validate( except ValidationError as e: output.error_list.append(DataFormatError(e)) - if mode is ValidationMode.MINIMUM: - return output - - if mode is ValidationMode.PEDANTIC: - warning_list = output.error_list - else: - warning_list = output.warning_list + output.warning_list += context["warning_list"] - warning_list += context["warning_list"] + # Inspect every sub-blueprint and concatenate all errors and warnings + for blueprintable in self.blueprints: + subresult = blueprintable.validate(mode, force) + output.error_list.extend(subresult.error_list) + output.warning_list.extend(subresult.warning_list) # if len(output.error_list) == 0: # # Set the `is_valid` attribute @@ -455,42 +523,42 @@ def validate( return output - def inspect(self) -> ValidationResult: - result = super().inspect() - - # By nature of necessity, we must ensure that all members of upgrade - # planner are in a correct and known format, so we must call: - try: - self.validate() - except Exception as e: - # If validation fails, it's in a format that we do not expect; and - # therefore unreasonable for us to assume that we can continue - # checking for issues relating to that non-existent format. - # Therefore, we add the errors to the error list and early exit - # TODO: figure out the proper way to reraise - result.error_list.append(DataFormatError(str(e))) - return result - - # Warn if active_index is out of reasonable bounds - if not 0 <= self.active_index < len(self.blueprints): - result.warning_list.append( - IndexWarning( - "'active_index' ({}) must be in range [0, {}) or else it will have no effect".format( - self.active_index, - len(self.blueprints), - ) - ) - ) - - # Inspect every sub-blueprint and concatenate all errors and warnings - for blueprintable in self.blueprints: - subresult = blueprintable.inspect() - result.error_list.extend(subresult.error_list) - result.warning_list.extend(subresult.warning_list) - - return result - - def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: + # def inspect(self) -> ValidationResult: + # result = super().inspect() + + # # By nature of necessity, we must ensure that all members of upgrade + # # planner are in a correct and known format, so we must call: + # try: + # self.validate() + # except Exception as e: + # # If validation fails, it's in a format that we do not expect; and + # # therefore unreasonable for us to assume that we can continue + # # checking for issues relating to that non-existent format. + # # Therefore, we add the errors to the error list and early exit + # # TODO: figure out the proper way to reraise + # result.error_list.append(DataFormatError(str(e))) + # return result + + # # Warn if active_index is out of reasonable bounds + # if not 0 <= self.active_index < len(self.blueprints): + # result.warning_list.append( + # IndexWarning( + # "'active_index' ({}) must be in range [0, {}) or else it will have no effect".format( + # self.active_index, + # len(self.blueprints), + # ) + # ) + # ) + + # # Inspect every sub-blueprint and concatenate all errors and warnings + # for blueprintable in self.blueprints: + # subresult = blueprintable.inspect() + # result.error_list.extend(subresult.error_list) + # result.warning_list.extend(subresult.warning_list) + + # return result + + def to_dict(self, exclude_none: bool = True, exclude_defaults: bool = True) -> dict: """ Returns the blueprint as a dictionary. Intended for getting the precursor to a Factorio blueprint string before encoding and compression @@ -499,12 +567,16 @@ def to_dict(self, exclude_none: bool=True, exclude_defaults: bool=True) -> dict: :returns: The dict representation of the BlueprintBook. """ # Create a copy, omitting blueprints because that part is special - result = super().to_dict(exclude_none=exclude_none, exclude_defaults=exclude_defaults) + result = super().to_dict( + exclude_none=exclude_none, exclude_defaults=exclude_defaults + ) # Transform blueprints converted_blueprints = [] for i, blueprintable in enumerate(self.blueprints): - blueprintable_entry = blueprintable.to_dict(exclude_none=exclude_none, exclude_defaults=exclude_defaults) + blueprintable_entry = blueprintable.to_dict( + exclude_none=exclude_none, exclude_defaults=exclude_defaults + ) # Users can manually customize index, we only override if none is found if "index" not in blueprintable_entry: blueprintable_entry["index"] = i diff --git a/draftsman/classes/blueprintable.py b/draftsman/classes/blueprintable.py index c02489a..177d303 100644 --- a/draftsman/classes/blueprintable.py +++ b/draftsman/classes/blueprintable.py @@ -8,14 +8,14 @@ from draftsman.constants import ValidationMode from draftsman.data.signals import signal_dict from draftsman.error import DataFormatError, IncorrectBlueprintTypeError -from draftsman.signatures import DraftsmanBaseModel, Icons, uint16, uint64 +from draftsman.signatures import DraftsmanBaseModel, Icon, uint16, uint64 from draftsman.utils import ( - encode_version, - decode_version, - JSON_to_string, - string_to_JSON, - reissue_warnings, - version_tuple_to_string + encode_version, + decode_version, + JSON_to_string, + string_to_JSON, + reissue_warnings, + version_tuple_to_string, ) from abc import ABCMeta, abstractmethod @@ -61,7 +61,14 @@ def __init__( self._root_item = root_item # self._root_format = (root_format,) self._root_format = root_format - self._root = self.Format.model_construct(**{self._root_item: {"item": item, **kwargs}}) + # self._root = self.Format.model_construct(**{self._root_item: {"item": item, **kwargs}}) + # self._root[self._root_item] = root_format.model_construct(self._root[self._root_item]) + self._root = self.Format.model_validate( + {self._root_item: {"item": item, **kwargs}}, + context={"construction": True, "mode": ValidationMode.MINIMUM}, + ) + # print("blueprintable") + # print(self._root) # self._root = {} # self._root[self._root_item] = {} @@ -238,7 +245,7 @@ def description(self, value: Optional[str]): # ========================================================================= @property - def icons(self) -> Optional[Icons]: + def icons(self) -> Optional[list[Icon]]: """ The visible icons of the blueprintable, shown in as the objects icon. @@ -272,7 +279,7 @@ def icons(self) -> Optional[Icons]: return self._root[self._root_item]["icons"] @icons.setter - def icons(self, value: Union[list[str], Icons, None]): + def icons(self, value: Union[list[str], list[Icon], None]): if self.validate_assignment: result = attempt_and_reissue( self, self._root_format, self._root[self._root_item], "icons", value @@ -336,7 +343,7 @@ def version(self) -> Optional[uint64]: return self._root[self._root_item].get("version", None) @version.setter - def version(self, value: Union[int, Sequence[int]]): + def version(self, value: Union[uint64, Sequence[uint16]]): if self.validate_assignment: result = attempt_and_reissue( self, self._root_format, self._root[self._root_item], "version", value @@ -497,15 +504,7 @@ def validate( except ValidationError as e: output.error_list.append(DataFormatError(e)) - if mode is ValidationMode.MINIMUM: - return output - - if mode is ValidationMode.PEDANTIC: - warning_list = output.error_list - else: - warning_list = output.warning_list - - warning_list += context["warning_list"] + output.warning_list += context["warning_list"] # if len(output.error_list) == 0: # # Set the `is_valid` attribute diff --git a/draftsman/classes/collection.py b/draftsman/classes/collection.py index a31b230..db8b092 100644 --- a/draftsman/classes/collection.py +++ b/draftsman/classes/collection.py @@ -20,6 +20,7 @@ EntityNotCircuitConnectableError, ) from draftsman.signatures import Connections +from draftsman.types import RollingStock from draftsman.warning import ( ConnectionSideWarning, ConnectionDistanceWarning, @@ -28,20 +29,12 @@ from draftsman.utils import AABB, PrimitiveAABB, flatten_entities, distance import abc -import itertools import math -import six -from typing import Union, Sequence +from typing import Optional, Sequence, Union import warnings -# TODO: move this -from draftsman.entity import Locomotive, CargoWagon, FluidWagon, ArtilleryWagon -RollingStock = Union[Locomotive, CargoWagon, FluidWagon, ArtilleryWagon] - - -@six.add_metaclass(abc.ABCMeta) -class EntityCollection(object): +class EntityCollection(metaclass=abc.ABCMeta): """ Abstract class used to describe an object that can contain a list of :py:class:`~draftsman.classes.entitylike.EntityLike` instances. @@ -52,16 +45,14 @@ class EntityCollection(object): # ========================================================================= @abc.abstractproperty - def entities(self): # pragma: no coverage - # type: () -> EntityList + def entities(self) -> EntityList: # pragma: no coverage """ Object that holds the ``EntityLikes``, usually a :py:class:`.EntityList`. """ pass @abc.abstractproperty - def entity_map(self): # pragma: no coverage - # type: () -> SpatialDataStructure + def entity_map(self) -> SpatialDataStructure: # pragma: no coverage """ Object that holds references to the entities organized by their spatial position. An implementation of :py:class:`.SpatialDataStructure`. @@ -69,8 +60,7 @@ def entity_map(self): # pragma: no coverage pass @abc.abstractproperty - def schedules(self): # pragma: no coverage - # type: () -> ScheduleList + def schedules(self) -> ScheduleList: # pragma: no coverage """ Object that holds any :py:class:`Schedule` objects within the collection, usually a :py:class:`ScheduleList`. @@ -78,8 +68,7 @@ def schedules(self): # pragma: no coverage pass @property - def rotatable(self): - # type: () -> bool + def rotatable(self) -> bool: """ Whether or not this collection can be rotated or not. Included for posterity; always returns True, even when containing entities that have @@ -90,8 +79,7 @@ def rotatable(self): return True @property - def flippable(self): - # type: () -> bool + def flippable(self) -> bool: """ Whether or not this collection can be flipped or not. This is determined by whether or not any of the entities contained can be flipped or not. @@ -109,8 +97,7 @@ def flippable(self): # Custom edge functions for EntityList interaction # ========================================================================= - def on_entity_insert(self, entitylike, merge): # pragma: no coverage - # type: (EntityLike, bool) -> EntityLike + def on_entity_insert(self, entitylike: EntityLike, merge: bool) -> Optional[EntityLike]: # pragma: no coverage """ Function called when an :py:class:`.EntityLike` is inserted into this object's :py:attr:`entities` list (assuming that the ``entities`` list @@ -119,8 +106,7 @@ def on_entity_insert(self, entitylike, merge): # pragma: no coverage """ pass - def on_entity_set(self, old_entitylike, new_entitylike): # pragma: no coverage - # type: (EntityLike, EntityLike) -> None + def on_entity_set(self, old_entitylike: EntityLike, new_entitylike: EntityLike) -> None: # pragma: no coverage """ Function called when an :py:class:`.EntityLike` is replaced with another in this object's :py:attr:`entities` list (assuming that the ``entities`` @@ -130,8 +116,7 @@ def on_entity_set(self, old_entitylike, new_entitylike): # pragma: no coverage """ pass - def on_entity_remove(self, entitylike): # pragma: no coverage - # type: (EntityLike) -> None + def on_entity_remove(self, entitylike: EntityLike) -> None: # pragma: no coverage """ Function called when an :py:class:`.EntityLike` is removed from this object's :py:attr:`entities` list (assuming that the ``entities`` list @@ -774,8 +759,8 @@ def add_circuit_connection(self, color, entity_1, entity_2, side1=1, side2=1): # Add entity_2 to entity_1.connections - if entity_1.connections[str(side1)] is None: - entity_1.connections[str(side1)] = Connections.CircuitConnections() + # if entity_1.connections[str(side1)] is None: + # entity_1.connections[str(side1)] = Connections.CircuitConnections() current_side = entity_1.connections[str(side1)] # if color not in current_side: @@ -795,8 +780,8 @@ def add_circuit_connection(self, color, entity_1, entity_2, side1=1, side2=1): # Add entity_1 to entity_2.connections - if entity_2.connections[str(side2)] is None: - entity_2.connections[str(side2)] = Connections.CircuitConnections() + # if entity_2.connections[str(side2)] is None: + # entity_2.connections[str(side2)] = Connections.CircuitConnections() current_side = entity_2.connections[str(side2)] # if color not in current_side: @@ -882,7 +867,7 @@ def remove_circuit_connection(self, color, entity_1, entity_2, side1=1, side2=1) entity_1.connections[str(side1)][color] = None # if len(current_side) == 0: # del entity_1.connections[str(side1)] - except (TypeError, KeyError, ValueError, AttributeError): # TODO: fix + except (TypeError, KeyError, ValueError, AttributeError): # TODO: fix pass # Remove from target @@ -901,7 +886,7 @@ def remove_circuit_connection(self, color, entity_1, entity_2, side1=1, side2=1) entity_2.connections[str(side2)][color] = None # if len(current_side) == 0: # del entity_2.connections[str(side2)] - except (TypeError, KeyError, ValueError, AttributeError): # TODO: fix + except (TypeError, KeyError, ValueError, AttributeError): # TODO: fix pass def remove_circuit_connections(self): @@ -1061,8 +1046,8 @@ def add_train_at_station(self, config, station, schedule=None): def set_train_schedule( self, - train_cars: Locomotive | list[RollingStock], - schedule: Schedule | None, + train_cars: Union[RollingStock, list[RollingStock]], + schedule: Optional[Schedule], ): """ Sets the schedule of any entity. @@ -1203,8 +1188,6 @@ def traverse_neighbours( if neighbour in used_wagons: continue - print(neighbour) - # If the orientation is +- 45 degrees in either direction from # the current wagon's orientation, it may be connected wagon_dir = float(wagon.orientation) @@ -1480,16 +1463,14 @@ def test(train): # ============================================================================= -@six.add_metaclass(abc.ABCMeta) -class TileCollection(object): +class TileCollection(metaclass=abc.ABCMeta): """ Abstract class used to describe an object that can contain a list of :py:class:`.Tile` instances. """ @abc.abstractproperty - def tiles(self): # pragma: no coverage - # type: () -> TileList + def tiles(self) -> TileList: # pragma: no coverage """ Object that holds the ``Tiles``, usually a :py:class:`~draftsman.classes.tilelist.TileList`. @@ -1497,8 +1478,7 @@ def tiles(self): # pragma: no coverage pass @abc.abstractproperty - def tile_map(self): # pragma: no coverage - # type: () -> SpatialDataStructure + def tile_map(self) -> SpatialDataStructure: # pragma: no coverage """ Object that holds the spatial information for the Tiles of this object, usually a :py:class:`~draftsman.classes.spatialhashmap.SpatialHashMap`. @@ -1509,8 +1489,7 @@ def tile_map(self): # pragma: no coverage # Custom edge functions for TileList interaction # ========================================================================= - def on_tile_insert(self, tile, merge): # pragma: no coverage - # type: (Tile, bool) -> Tile + def on_tile_insert(self, tile: Tile, merge: bool) -> Optional[Tile]: # pragma: no coverage """ Function called when an :py:class:`.Tile` is inserted into this object's :py:attr:`tiles` list (assuming that the ``tiles`` list is a @@ -1519,8 +1498,7 @@ def on_tile_insert(self, tile, merge): # pragma: no coverage """ pass - def on_tile_set(self, old_tile, new_tile): # pragma: no coverage - # type: (Tile, bool) -> None + def on_tile_set(self, old_tile: Tile, new_tile: Tile) -> None: # pragma: no coverage """ Function called when an :py:class:`.Tile` is replaced with another in this object's :py:attr:`tiles` list (assuming that the ``tiles`` list is @@ -1529,8 +1507,7 @@ def on_tile_set(self, old_tile, new_tile): # pragma: no coverage """ pass - def on_tile_remove(self, tile): # pragma: no coverage - # type: (Tile) -> None + def on_tile_remove(self, tile: Tile) -> None: # pragma: no coverage """ Function called when an :py:class:`.Tile` is removed from this object's :py:attr:`tiles` list (assuming that the ``entities`` list is a @@ -1543,8 +1520,7 @@ def on_tile_remove(self, tile): # pragma: no coverage # Queries # ========================================================================= - def find_tile(self, position): - # type: (Union[Vector, PrimitiveVector]) -> Tile + def find_tile(self, position: Union[Vector, PrimitiveVector]) -> Tile: """ Returns the tile at the tile coordinate ``position``. If there are multiple tiles at that location, the entity that was inserted first is @@ -1561,8 +1537,8 @@ def find_tile(self, position): except IndexError: return None - def find_tiles_filtered(self, **kwargs): - # type: (**dict) -> list[Tile] + def find_tiles_filtered(self, **kwargs) -> list[Tile]: + # TODO: keyword arguments """ Returns a filtered list of tiles within the blueprint. Works similarly to diff --git a/draftsman/classes/deconstruction_planner.py b/draftsman/classes/deconstruction_planner.py index 02ae237..6b2684d 100644 --- a/draftsman/classes/deconstruction_planner.py +++ b/draftsman/classes/deconstruction_planner.py @@ -48,21 +48,30 @@ from draftsman.classes.blueprintable import Blueprintable from draftsman.classes.exportable import ValidationResult, attempt_and_reissue from draftsman.constants import FilterMode, TileSelectionMode, ValidationMode -from draftsman.data import entities, tiles +from draftsman.data import items from draftsman.error import DataFormatError from draftsman.warning import IndexWarning, UnknownEntityWarning, UnknownTileWarning from draftsman.signatures import ( - Icons, + Icon, DraftsmanBaseModel, EntityFilter, TileFilter, uint8, uint16, uint64, + EntityName, + TileName, ) from draftsman.utils import encode_version, reissue_warnings -from pydantic import ConfigDict, Field, field_validator +from pydantic import ( + ConfigDict, + Field, + ValidatorFunctionWrapHandler, + ValidationInfo, + ValidationError, + field_validator, +) from typing import Any, Literal, Optional, Sequence, Union @@ -84,7 +93,7 @@ class DeconstructionPlannerObject(DraftsmanBaseModel): description=""" The item that this DeconstructionItem object is associated with. Always equivalent to 'deconstruction-planner'. - """ + """, ) label: Optional[str] = Field( None, @@ -116,7 +125,7 @@ class Settings(DraftsmanBaseModel): A string description given to this DeconstructionPlanner. """, ) - icons: Optional[Icons] = Field( + icons: Optional[list[Icon]] = Field( None, description=""" A set of signal pictures to associate with this @@ -129,7 +138,7 @@ class Settings(DraftsmanBaseModel): description=""" Whether to treat the 'entity_filters' list as a whitelist or blacklist, where 0 is whitelist and 1 is blacklist. - """ + """, ) entity_filters: Optional[list[EntityFilter]] = Field( [], @@ -137,7 +146,7 @@ class Settings(DraftsmanBaseModel): Either a list of entities to deconstruct, or a list of entities to not deconstruct, depending on the value of 'entity_filter_mode'. - """ + """, ) trees_and_rocks_only: Optional[bool] = Field( False, @@ -145,7 +154,7 @@ class Settings(DraftsmanBaseModel): Whether or not to only deconstruct environmental objects like trees and rocks. If true, all 'entity_filters' and 'tile_filters' are ignored, regardless of their modes. - """ + """, ) tile_filter_mode: Optional[FilterMode] = Field( @@ -153,14 +162,14 @@ class Settings(DraftsmanBaseModel): description=""" Whether to treat the 'tile_filters' list as a whitelist or blacklist, where 0 is whitelist and 1 is blacklist. - """ + """, ) tile_filters: Optional[list[TileFilter]] = Field( [], description=""" Either a list of tiles to deconstruct, or a list of tiles to not deconstruct, depending on the value of - 'tile_filter_mode'.""" + 'tile_filter_mode'.""", ) tile_selection_mode: Optional[TileSelectionMode] = Field( TileSelectionMode.NEVER, @@ -176,25 +185,67 @@ class Settings(DraftsmanBaseModel): defined in 'tile_filters', they are ignored, 3 (Only): Only tiles are selected; if there are entities defined in 'entity_filters', they are ignored. - """ + """, ) + @field_validator("icons", mode="before") + @classmethod + def normalize_icons(cls, value: Any): + if isinstance(value, Sequence): + result = [None] * len(value) + for i, signal in enumerate(value): + if isinstance(signal, str): + result[i] = {"index": i + 1, "signal": signal} + else: + result[i] = signal + return result + else: + return value + + @field_validator("entity_filters", mode="before") + @classmethod + def normalize_entity_filters(cls, value: Any): + if isinstance(value, (list, tuple)): + result = [] + for i, entity in enumerate(value): + if isinstance(entity, str): + result.append({"index": i + 1, "name": entity}) + else: + result.append(entity) + return result + else: + return value + + @field_validator("tile_filters", mode="before") + @classmethod + def normalize_tile_filters(cls, value: Any): + if isinstance(value, (list, tuple)): + result = [] + for i, tile in enumerate(value): + if isinstance(tile, str): + result.append({"index": i + 1, "name": tile}) + else: + result.append(tile) + return result + else: + return value + settings: Optional[Settings] = Settings() @field_validator("version", mode="before") @classmethod def normalize_to_int(cls, value: Any): - if isinstance(value, Sequence): + if isinstance(value, (list, tuple)): return encode_version(*value) return value deconstruction_planner: DeconstructionPlannerObject index: Optional[uint16] = Field( - None, + None, description=""" The index of the blueprint inside a parent BlueprintBook's blueprint list. Only meaningful when this object is inside a BlueprintBook. - """ + """, ) model_config = ConfigDict(title="DeconstructionPlanner") @@ -204,8 +255,8 @@ def normalize_to_int(cls, value: Any): # ========================================================================= def __init__( - self, - deconstruction_planner: Union[str, dict] = None, + self, + deconstruction_planner: Union[str, dict] = None, index: uint16 = None, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -229,55 +280,48 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) @reissue_warnings def setup( - self, + self, label: str = None, version: uint64 = __factorio_version_info__, - settings: Format.DeconstructionPlannerObject.Settings = Format.DeconstructionPlannerObject.Settings(), + settings: Format.DeconstructionPlannerObject.Settings = {}, index: uint16 = None, **kwargs ): - # self._root[self._root_item]["settings"] = __class__.Format.DeconstructionPlannerObject.Settings() - # Item (type identifier) - # self._root[self._root_item]["item"] = "deconstruction-planner" kwargs.pop("item", None) self.label = label self.version = version - self._root[self._root_item]["settings"] = settings - # settings = kwargs.pop("settings", None) - # if settings is not None: - # self.entity_filter_mode = settings.pop("entity_filter_mode", None) - # self.entity_filters = settings.pop("entity_filter_mode", None) - # self.trees_and_rocks_only = settings.pop("trees_and_rocks_only", None) + if settings is not None: + self.entity_filter_mode = settings.get( + "entity_filter_mode", FilterMode.WHITELIST + ) + self.entity_filters = settings.get("entity_filters", []) + self.trees_and_rocks_only = settings.get("trees_and_rocks_only", False) - # self.tile_filter_mode = settings.pop("tile_filter_mode", None) - # self.tile_filters = settings.pop("tile_filters", None) - # self.tile_selection_mode = settings.pop("tile_selection_mode", None) + self.tile_filter_mode = settings.get( + "tile_filter_mode", FilterMode.WHITELIST + ) + self.tile_filters = settings.get("tile_filters", []) + self.tile_selection_mode = settings.get( + "tile_selection_mode", TileSelectionMode.NEVER + ) - # self.description = settings.pop("description", None) - # self.icons = settings.pop("icons", None) + self.description = settings.get("description", None) + self.icons = settings.get("icons", None) self.index = index # A bit scuffed, but + # Issue warnings for any keyword not recognized by UpgradePlanner for kwarg, value in kwargs.items(): self._root[kwarg] = value - # Issue warnings for any keyword not recognized by UpgradePlanner - # for unused_arg in kwargs: - # warnings.warn( - # "{} has no attribute '{}'".format(type(self), unused_arg), - # DraftsmanWarning, - # stacklevel=2, - # ) - # ========================================================================= # Properties # ========================================================================= @@ -287,7 +331,7 @@ def entity_filter_count(self) -> uint8: """ TODO """ - return entities.raw[self.item].get("entity_filter_count", 0) + return items.raw[self.item].get("entity_filter_count", 0) # ========================================================================= @@ -296,7 +340,47 @@ def tile_filter_count(self) -> uint8: """ TODO """ - return entities.raw[self.item].get("tile_filter_count", 0) + return items.raw[self.item].get("tile_filter_count", 0) + + # ========================================================================= + + @property + def description(self) -> Optional[str]: + return self._root[self._root_item]["settings"].get("description", None) + + @description.setter + def description(self, value: Optional[str]): + if self.validate_assignment: + result = attempt_and_reissue( + self, + self.Format.DeconstructionPlannerObject.Settings, + self._root[self._root_item]["settings"], + "description", + value, + ) + self._root[self._root_item]["settings"]["description"] = result + else: + self._root[self._root_item]["settings"]["description"] = value + + # ========================================================================= + + @property + def icons(self) -> Optional[list[Icon]]: + return self._root[self._root_item]["settings"].get("icons", None) + + @icons.setter + def icons(self, value: Union[list[str], list[Icon], None]): + if self.validate_assignment: + result = attempt_and_reissue( + self, + self.Format.DeconstructionPlannerObject.Settings, + self._root[self._root_item]["settings"], + "icons", + value, + ) + self._root[self._root_item]["settings"]["icons"] = result + else: + self._root[self._root_item]["settings"]["icons"] = value # ========================================================================= @@ -323,7 +407,7 @@ def entity_filter_mode(self, value: Optional[FilterMode]): self.Format.DeconstructionPlannerObject.Settings, self._root[self._root_item]["settings"], "entity_filter_mode", - value + value, ) self._root[self._root_item]["settings"]["entity_filter_mode"] = result else: @@ -342,14 +426,13 @@ def entity_filters(self) -> Optional[list[EntityFilter]]: @entity_filters.setter def entity_filters(self, value: Optional[list[EntityFilter]]): - # TODO: what if index >= self.entity_filter_count? if self.validate_assignment: result = attempt_and_reissue( self, self.Format.DeconstructionPlannerObject.Settings, self._root[self._root_item]["settings"], "entity_filters", - value + value, ) self._root[self._root_item]["settings"]["entity_filters"] = result else: @@ -379,7 +462,7 @@ def trees_and_rocks_only(self, value: Optional[bool]): self.Format.DeconstructionPlannerObject.Settings, self._root[self._root_item]["settings"], "trees_and_rocks_only", - value + value, ) self._root[self._root_item]["settings"]["trees_and_rocks_only"] = result else: @@ -410,7 +493,7 @@ def tile_filter_mode(self, value: Optional[FilterMode]): self.Format.DeconstructionPlannerObject.Settings, self._root[self._root_item]["settings"], "tile_filter_mode", - value + value, ) self._root[self._root_item]["settings"]["tile_filter_mode"] = result else: @@ -434,7 +517,7 @@ def tile_filters(self, value: Optional[list[TileFilter]]): self.Format.DeconstructionPlannerObject.Settings, self._root[self._root_item]["settings"], "tile_filters", - value + value, ) self._root[self._root_item]["settings"]["tile_filters"] = result else: @@ -471,7 +554,7 @@ def tile_selection_mode(self, value: Optional[TileSelectionMode]): self.Format.DeconstructionPlannerObject.Settings, self._root[self._root_item]["settings"], "tile_selection_mode", - value + value, ) self._root[self._root_item]["settings"]["tile_selection_mode"] = result else: @@ -481,8 +564,7 @@ def tile_selection_mode(self, value: Optional[TileSelectionMode]): # Utility functions # ========================================================================= - def set_entity_filter(self, index, name): - # type: (int, str) -> None + def set_entity_filter(self, index: uint64, name: EntityName): """ Sets an entity filter in the list of entity filters. Appends the new one to the end of the list regardless of the ``index``. If ``index`` is @@ -494,41 +576,52 @@ def set_entity_filter(self, index, name): :param name: The name of the entity to filter for deconstruction. """ # TODO: sorting with bisect - if self.entity_filters is None: - self.entity_filters = [] - - # Check if index is ouside the range of the max filter slots - # if not 0 <= index < 30: - # raise IndexError( - # "Index {} exceeds the maximum number of entity filter slots (30)".format( - # index - # ) - # ) - - # Check to see that `name` is a valid entity - # TODO - - for i in range(len(self.entity_filters)): - filter = self.entity_filters[i] + if name is not None: + try: + new_entry = EntityFilter(index=index, name=name) + new_entry.index += 1 + except ValidationError as e: + raise DataFormatError(e) from None + + new_filters = self.entity_filters if self.entity_filters is not None else [] + + found_index = None + for i, filter in enumerate(new_filters): if filter["index"] == index + 1: if name is None: - del self.entity_filters[i] + del new_filters[i] else: filter["name"] = name - return - - # Otherwise its unique; add to list - self.entity_filters.append({"index": index + 1, "name": name}) + found_index = i + break + + if found_index is None: + # Otherwise its unique; add to list + new_filters.append(new_entry) + + result = attempt_and_reissue( + self, + self.Format.DeconstructionPlannerObject.Settings, + self._root[self._root_item]["settings"], + "entity_filters", + new_filters, + ) + self._root[self._root_item]["settings"]["entity_filters"] = result - def set_entity_filters(self, *entity_names: list[str]): + def set_entity_filters(self, *entity_filters: list[str]): """ TODO """ - for i, entity_name in enumerate(entity_names): - self.set_entity_filter(i, entity_name) + result = attempt_and_reissue( + self, + self.Format.DeconstructionPlannerObject.Settings, + self._root[self._root_item]["settings"], + "entity_filters", + entity_filters, + ) + self._root[self._root_item]["settings"]["entity_filters"] = result - def set_tile_filter(self, index, name): - # type: (int, str) -> None + def set_tile_filter(self, index: uint64, name: TileName): """ Sets a tile filter in the list of tile filters. Appends the new one to the end of the list regardless of the ``index``. If ``index`` is @@ -539,31 +632,38 @@ def set_tile_filter(self, index, name): :param index: The index to set the new filter at. :param name: The name of the tile to filter for deconstruction. """ - if self.tile_filters is None: - self.tile_filters = [] - - # Check if `index` is ouside the range of the max filter slots - # if not 0 <= index < 30: - # raise IndexError( - # "Index {} exceeds the maximum number of tile filter slots (30)".format( - # index - # ) - # ) - - # Check to see that `name` is a valid tile - # TODO - - for i in range(len(self.tile_filters)): - filter = self.tile_filters[i] + # TODO: sorting with bisect + if name is not None: + try: + new_entry = TileFilter(index=index, name=name) + new_entry.index += 1 + except ValidationError as e: + raise DataFormatError(e) from None + + new_filters = self.tile_filters if self.tile_filters is not None else [] + + found_index = None + for i, filter in enumerate(new_filters): if filter["index"] == index + 1: if name is None: - del self.tile_filters[i] + del new_filters[i] else: filter["name"] = name - return - - # Otherwise its unique; add to list - self.tile_filters.append({"index": index + 1, "name": name}) + found_index = i + break + + if found_index is None: + # Otherwise its unique; add to list + new_filters.append(new_entry) + + result = attempt_and_reissue( + self, + self.Format.DeconstructionPlannerObject.Settings, + self._root[self._root_item]["settings"], + "tile_filters", + new_filters, + ) + self._root[self._root_item]["settings"]["tile_filters"] = result def set_tile_filters(self, *tile_names: list[str]): """ @@ -571,96 +671,3 @@ def set_tile_filters(self, *tile_names: list[str]): """ for i, tile_name in enumerate(tile_names): self.set_tile_filter(i, tile_name) - - # ========================================================================= - - # def validate(self): - # """ - # TODO - # """ - # if self.is_valid: - # return - - # # TODO: wrap with DataFormatError or similar - # # TODO: this is a bit confusingly named, but it shouldn't matter for - # # the end user - # DeconstructionPlanner.Format.DeconstructionPlannerObject.model_validate( - # self._root - # ) - - # super().validate() - - def inspect(self) -> ValidationResult: - result = super().inspect() - - # By nature of necessity, we must ensure that all members of upgrade - # planner are in a correct and known format, so we must call: - try: - self.validate() - except Exception as e: - # If validation fails, it's in a format that we do not expect; and - # therefore unreasonable for us to assume that we can continue - # checking for issues relating to that non-existent format. - # Therefore, we add the errors to the error list and early exit - # TODO: figure out the proper way to reraise - result.error_list.append(DataFormatError(str(e))) - return result - - for entity_filter in self.entity_filters: - if not 0 <= entity_filter["index"] < 30: - result.warning_list.append( - IndexWarning( - "Index of entity_filter '{}' ({}) exceeds the maximum number of tile filter slots (30)".format( - entity_filter["name"], entity_filter["index"] - ) - ) - ) - - if entity_filter["name"] not in entities.raw: - result.warning_list.append( - UnknownEntityWarning( - "Unrecognized entity '{}'".format(entity_filter["name"]) - ) - ) - - for tile_filter in self.tile_filters: - if not 0 <= tile_filter["index"] < 30: - result.warning_list.append( - IndexWarning( - "Index of entity_filter '{}' ({}) exceeds the maximum number of tile filter slots (30)".format( - tile_filter["name"], tile_filter["index"] - ) - ) - ) - - if tile_filter["name"] not in tiles.raw: - result.warning_list.append( - UnknownTileWarning( - "Unrecognized tile '{}'".format(entity_filter["name"]) - ) - ) - - return result - - # def to_dict(self): - # out_model = DeconstructionPlanner.Format.model_construct(**self._root) - # out_model.deconstruction_planner = ( - # DeconstructionPlanner.Format.DeconstructionPlannerObject.model_construct( - # **out_model.deconstruction_planner - # ) - # ) - # out_model.deconstruction_planner.settings = DeconstructionPlanner.Format.DeconstructionPlannerObject.Settings.model_construct( - # **out_model.deconstruction_planner.settings - # ) - - # print(out_model) - - # # We then create an output dict - # out_dict = out_model.model_dump( - # by_alias=True, - # exclude_none=True, - # exclude_defaults=True, - # warnings=False, # Ignore warnings until model_construct is recursive - # ) - - # return out_dict diff --git a/draftsman/classes/entity.py b/draftsman/classes/entity.py index 0e9b78a..90cac25 100644 --- a/draftsman/classes/entity.py +++ b/draftsman/classes/entity.py @@ -20,8 +20,14 @@ from draftsman.constants import ValidationMode from draftsman.data import entities from draftsman.error import InvalidEntityError, DraftsmanError, DataFormatError -from draftsman.signatures import uint64, IntPosition, FloatPosition -from draftsman.signatures import DraftsmanBaseModel, get_suggestion +from draftsman.signatures import ( + DraftsmanBaseModel, + FloatPosition, + IntPosition, + get_suggestion, + uint64, + EntityName, +) from draftsman.warning import UnknownEntityWarning from draftsman import utils @@ -32,6 +38,7 @@ GetCoreSchemaHandler, ValidationError, ValidationInfo, + ValidatorFunctionWrapHandler, model_validator, field_validator, field_serializer, @@ -61,9 +68,13 @@ def y(self, value): value - self.entity().tile_height / 2 ) - @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: - return core_schema.no_info_after_validator_function(cls, handler(FloatPosition)) # TODO: correct annotation + # @classmethod + # def __get_pydantic_core_schema__( + # cls, _source_type: Any, handler: GetCoreSchemaHandler + # ) -> CoreSchema: + # return core_schema.no_info_after_validator_function( + # cls, handler(FloatPosition) + # ) # TODO: correct annotation class _TileVector(Vector): @@ -141,30 +152,32 @@ class Format(DraftsmanBaseModel): @field_validator("name") @classmethod - def check_recognized(cls, name: str, info: ValidationInfo): + def check_recognized(cls, value: str, info: ValidationInfo): if not info.context: - return name - if info.context["mode"] is ValidationMode.MINIMUM: - return name + return value + if info.context["mode"] <= ValidationMode.MINIMUM: + return value warning_list: list = info.context["warning_list"] entity: Entity = info.context["object"] - if name not in entity.similar_entities: - issue = UnknownEntityWarning( - "'{}' is not a known name for this {}{}".format( - name, - type(entity).__name__, - get_suggestion(name, entity.similar_entities, n=1), + # Similar entities exists on all entities EXCEPT generic `Entity` + # instances, for which we're trying to ignore validation on + if entity.similar_entities is None: + return value + + if value not in entity.similar_entities: + warning_list.append( + UnknownEntityWarning( + "'{}' is not a known name for this {}{}".format( + value, + type(entity).__name__, + get_suggestion(value, entity.similar_entities, n=1), + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - - return name + return value @field_serializer("position") def serialize_position(self, _): @@ -178,7 +191,7 @@ def __init__( self, name: str, similar_entities: list[str], - tile_position: IntPosition = [0, 0], + tile_position: IntPosition = (0, 0), id: str = None, **kwargs ): @@ -215,15 +228,20 @@ def __init__( if "position" in kwargs and kwargs["position"] is None: kwargs.pop("position") - self._root = self.Format.model_construct( - # If these two are omitted, included dummy values so that validation - # doesn't complain (since they're required fields) - # position={"x": 0, "y": 0}, - # entity_number=0, - # Add all remaining extra keywords; all recognized keywords will be - # accessed individually, but this allows us to catch the extra ones - # and issue warnings for them - **{"position": {"x": 0, "y": 0}, "entity_number": 0, **kwargs} + # self._root = self.Format.model_construct( + # # If these two are omitted, included dummy values so that validation + # # doesn't complain (since they're required fields) + # # position={"x": 0, "y": 0}, + # # entity_number=0, + # # Add all remaining extra keywords; all recognized keywords will be + # # accessed individually, but this allows us to catch the extra ones + # # and issue warnings for them + # **{"position": {"x": 0, "y": 0}, "entity_number": 0, **kwargs} + # ) + self._root = type(self).Format.model_validate( + {"name": name, "position": {"x": 0, "y": 0}, "entity_number": 0, **kwargs}, + strict=False, + context={"construction": True, "mode": ValidationMode.NONE}, ) # Private attributes @@ -247,7 +265,9 @@ def __init__( # Tile Width and Height (Internal) # Usually tile dimensions are implicitly based on the collision box self._tile_width, self._tile_height = utils.aabb_to_dimensions( - self.static_collision_set.get_bounding_box() if self.static_collision_set else None + self.static_collision_set.get_bounding_box() + if self.static_collision_set + else None ) # But sometimes it can be overrided in special cases (rails) if "tile_width" in entities.raw.get(self.name, {}): @@ -362,11 +382,11 @@ def id(self, value: str): self._id = value elif isinstance(value, str): if self.parent: - try: - old_id = self._id - self.parent.entities._remove_key(old_id) - except AttributeError: - pass + # try: + old_id = self._id + self.parent.entities._remove_key(old_id) + # except AttributeError: + # pass self._id = value self.parent.entities._set_key(self._id, self) else: @@ -664,9 +684,9 @@ def validate( try: result = self.Format.model_validate( - self._root, - strict=False, # TODO: ideally this should be strict - context=context + self._root, + strict=False, # TODO: ideally this should be strict + context=context, ) # Reassign private attributes result._entity = weakref.ref(self) @@ -683,40 +703,10 @@ def validate( except ValidationError as e: output.error_list.append(DataFormatError(e)) - if mode is ValidationMode.MINIMUM: - return output - - if mode is ValidationMode.PEDANTIC: - warning_list = output.error_list - else: - warning_list = output.warning_list - - warning_list += context["warning_list"] - - if len(output.error_list) == 0: - # Set the `is_valid` attribute - # This means that if mode="pedantic", an entity that issues only - # warnings will still not be considered valid - super().validate() + output.warning_list += context["warning_list"] return output - # def inspect(self): - # # type: () -> ValidationResult - # result = super().inspect() - - # try: - # self.validate() - # except DraftsmanError as e: - # result.error_list.append(e) - # return - - # # Warn if entity is unrecognized - # if self.name not in self.similar_entities: - # result.warning_list.append(DraftsmanWarning("Unrecognized entity {}".format(self.name))) - - # return result - def mergable_with(self, other: "Entity") -> bool: return ( type(self) == type(other) @@ -755,7 +745,7 @@ def __repr__(self) -> str: # pragma: no coverage id(self), str(self.to_dict()), ) - + def __deepcopy__(self, memo) -> "Entity": # Perform the normal deepcopy result = super().__deepcopy__(memo=memo) @@ -764,11 +754,10 @@ def __deepcopy__(self, memo) -> "Entity": # We need a reference to the parent entity stored in `_root` so that we # can properly serialize it's position # If we use a regular reference, it copies properly, but then it creates - # a circular reference which makes garbage collection worse - # We use a weakref to mitigate this memory issue (and ensure that + # a circular reference which makes garbage collection worse + # We use a weakref to mitigate this memory issue (and ensure that # deleting an entity immediately destroys it), but means that we have to # manually update it's reference here result._root._entity = weakref.ref(result) # Get me out of here return result - diff --git a/draftsman/classes/entity_list.py b/draftsman/classes/entity_list.py index 82eda09..af059f5 100644 --- a/draftsman/classes/entity_list.py +++ b/draftsman/classes/entity_list.py @@ -41,7 +41,9 @@ class Format(BaseModel): data: list[dict] # TODO: fix @utils.reissue_warnings - def __init__(self, parent: "EntityCollection"=None, initlist: list[EntityLike]=None): + def __init__( + self, parent: "EntityCollection" = None, initlist: list[EntityLike] = None + ): """ Instantiates a new ``EntityList``. @@ -72,8 +74,13 @@ def __init__(self, parent: "EntityCollection"=None, initlist: list[EntityLike]=N ) @utils.reissue_warnings - def append(self, name, copy=True, merge=False, **kwargs): - # type: (Union[str, EntityLike], bool, bool, **dict) -> None + def append( + self, + name: Union[str, "EntityLike"], + copy: bool = True, + merge: bool = False, + **kwargs + ): """ Appends the ``EntityLike`` to the end of the sequence. @@ -81,6 +88,10 @@ def append(self, name, copy=True, merge=False, **kwargs): an Entity as ``entity`` and any keyword arguments, which are appended to the constructor of that entity. + If ``copy`` is specified, a deepcopy of the passed in entity is created. + If any additional keyword arguments are specified alongside, the + attributes of that newly copied entity are overwritten with them. + :param name: Either a string reprenting the name of an ``Entity``, or an :py:class:`.EntityLike` instance. :param copy: Whether or not to create a copy of the passed in @@ -133,13 +144,18 @@ def append(self, name, copy=True, merge=False, **kwargs): assert inserter is blueprint.entities[-1] assert blueprint.entities[-1].stack_size_override == 1 """ - self.insert( - len(self.data), name, copy=copy, merge=merge, **kwargs - ) + # TODO: validate + self.insert(len(self.data), name, copy=copy, merge=merge, **kwargs) @utils.reissue_warnings - def insert(self, idx, name, copy=True, merge=False, **kwargs): - # type: (int, Union[str, EntityLike], bool, bool, **dict) -> None + def insert( + self, + idx: int, + name: Union[str, "EntityLike"], + copy: bool = True, + merge: bool = False, + **kwargs + ): """ Inserts an ``EntityLike`` into the sequence. @@ -147,6 +163,10 @@ def insert(self, idx, name, copy=True, merge=False, **kwargs): an Entity as ``entity`` and any keyword arguments, which are appended to the constructor of that entity. + If ``copy`` is specified, a deepcopy of the passed in entity is created. + If any additional keyword arguments are specified alongside, the + attributes of that newly copied entity are overwritten with them. + :param idx: The integer index to put the ``EntityLike``. :param name: Either a string reprenting the name of an ``Entity``, or an :py:class:`.EntityLike` instance. @@ -190,19 +210,18 @@ def insert(self, idx, name, copy=True, merge=False, **kwargs): assert inserter is blueprint.entities[0] assert blueprint.entities[0].stack_size_override == 1 """ + # TODO: validate # Convert to new Entity if constructed via string keyword new = False if isinstance(name, six.string_types): entitylike = new_entity(name, **kwargs) - if entitylike is None: - return new = True else: entitylike = name if copy and not new: - # Create a DEEPCopy of the entity if desired + # Create a DEEPcopy of the entity if desired entitylike = deepcopy(entitylike) # Overwrite any user keywords if specified in the function signature for k, v in kwargs.items(): @@ -243,8 +262,7 @@ def insert(self, idx, name, copy=True, merge=False, **kwargs): # that it's inserted entitylike._parent = self._parent - def recursive_remove(self, item): - # type: (EntityLike) -> None + def recursive_remove(self, item: "EntityLike"): """ Removes an EntityLike from the EntityList. Recurses through any subgroups to see if ``item`` is there, removing the root-most entity @@ -270,8 +288,7 @@ def recursive_remove(self, item): # If we've made it this far, it's not anywhere in the list raise ValueError - def check_entitylike(self, entitylike): - # type: (EntityLike) -> None + def check_entitylike(self, entitylike: "EntityLike"): """ A set of universal checks that all :py:class:`.EntityLike`s have to follow if they are to be added to this ``EntityList``. @@ -293,6 +310,10 @@ def check_entitylike(self, entitylike): raise DuplicateIDError(entitylike.id) # Warn if the placed entity is hidden + # TODO: move this elsewhere, perhaps to entity itself in case the user + # accidentally creates an instance of a hidden entity + # If not in the entity itself it would likely live in the `Format` of + # this class if getattr(entitylike, "hidden", False): warnings.warn( "Attempting to add hidden entity '{}'".format(entitylike.name), @@ -300,8 +321,7 @@ def check_entitylike(self, entitylike): stacklevel=2, ) - def get_pair(self, item): - # type: (Union[int, str]) -> tuple[int, str] + def get_pair(self, item: Union[int, str]) -> tuple[int, str]: """ Takes either an index or a key, finds the converse entry associated with it, and returns them both as a pair. @@ -318,44 +338,36 @@ def get_pair(self, item): else: return (item, self.idx_to_key.get(item, None)) - def union(self, other): - # type: (EntityList) -> EntityList + def union(self, other: "EntityList") -> "EntityList": """ TODO """ - if not isinstance(other, EntityList): # TODO: needed? - other = EntityList(initlist=other) - new_entity_list = EntityList() - for entity in self.data: + for entity in self: new_entity_list.append(entity) new_entity_list[-1]._parent = None - for other_entity in other.data: + for other_entity in other: already_in = False - for entity in self.data: + for entity in self: if entity == other_entity: already_in = True break if not already_in: - new_entity_list.append() + new_entity_list.append(other_entity) return new_entity_list - def intersection(self, other): - # type: (EntityList) -> EntityList + def intersection(self, other: "EntityList") -> "EntityList": """ TODO """ - if not isinstance(other, EntityList): # TODO: needed? - other = EntityList(initlist=other) - new_entity_list = EntityList() - for entity in self.data: + for entity in self: in_both = False - for other_entity in other.data: + for other_entity in other: if other_entity == entity: in_both = True break @@ -364,19 +376,15 @@ def intersection(self, other): return new_entity_list - def difference(self, other): - # type: (EntityList) -> EntityList + def difference(self, other: "EntityList") -> "EntityList": """ TODO """ - if not isinstance(other, EntityList): # TODO: needed? - other = EntityList(initlist=other) - new_entity_list = EntityList() - for entity in self.data: + for entity in self: different = True - for other_entity in other.data: + for other_entity in other: if other_entity == entity: different = False break @@ -395,8 +403,9 @@ def clear(self): # Metamethods # ========================================================================= - def __getitem__(self, item): - # type: (Union[int, str, slice]) -> Union[EntityLike, list[EntityLike]] + def __getitem__( + self, item: Union[int, str, slice] + ) -> Union[EntityLike, list[EntityLike]]: if isinstance(item, (list, tuple)): new_base = self[item[0]] item = item[1:] @@ -409,8 +418,7 @@ def __getitem__(self, item): return self.key_map[item] # Raises KeyError @utils.reissue_warnings - def __setitem__(self, item, value): - # type: (Union[int, str], EntityLike) -> None + def __setitem__(self, item: Union[int, str], value: "EntityLike"): # TODO: handle slices # Get the key and index of the item @@ -438,8 +446,7 @@ def __setitem__(self, item, value): # TODO: this sucks man self._parent.recalculate_area() - def __delitem__(self, item): - # type: (Union[int, str]) -> None + def __delitem__(self, item: Union[int, str]): if isinstance(item, slice): # Get slice parameters start, stop, step = item.indices(len(self)) @@ -483,8 +490,7 @@ def __delitem__(self, item): # TODO: this sucks man self._parent.recalculate_area() - def __len__(self): - # type: () -> int + def __len__(self) -> int: return len(self.data) def __contains__(self, item: EntityLike) -> bool: @@ -604,20 +610,22 @@ def try_to_replace_association(old): return new - def __repr__(self) -> str: + def __repr__(self) -> str: # pragma: no coverage return "{}".format(self.data) @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: - return core_schema.no_info_after_validator_function(cls, handler(list[dict])) # TODO: correct annotation - + def __get_pydantic_core_schema__( # pragma: no coverage + cls, _source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_after_validator_function( + cls, handler(list[dict]) # TODO: correct annotation + ) # pragma: no coverage # ========================================================================= # Internal functions # ========================================================================= - def _remove_key(self, key): - # type: (str) -> None + def _remove_key(self, key: str): """ Shorthand to remove ``key`` from the key mapping dictionaries. Does nothing if key is ``None``. @@ -633,8 +641,7 @@ def _remove_key(self, key): del self.key_to_idx[key] del self.idx_to_key[idx] - def _set_key(self, key, value): - # type: (str, EntityLike) -> None + def _set_key(self, key: str, value: "EntityLike"): """ Shorthand to set ``key`` in the key mapping dictionaries to point to ``value``. @@ -654,8 +661,7 @@ def _set_key(self, key, value): self.key_to_idx[key] = idx self.idx_to_key[idx] = key - def _shift_key_indices(self, idx, amt): - # type: (int, int) -> None + def _shift_key_indices(self, idx: int, amt: int): """ Shifts all of the key mappings above or equal to ``idx`` by ``amt``. Used when inserting or removing elements before the end, which moves diff --git a/draftsman/classes/exportable.py b/draftsman/classes/exportable.py index 4306f77..9bcab52 100644 --- a/draftsman/classes/exportable.py +++ b/draftsman/classes/exportable.py @@ -64,7 +64,7 @@ def reissue_all(self, stacklevel=2): for warning in self.warning_list: warnings.warn(warning, stacklevel=stacklevel) - def __eq__(self, other): + def __eq__(self, other): # pragma: no coverage # Primarily for test suite. if not isinstance(other, ValidationResult): return False @@ -118,6 +118,8 @@ def __init__(self): # of construction in the child-most class, if desired self._validate_assignment = ValidationMode.NONE + self._unknown_format = False + # ========================================================================= @property @@ -167,7 +169,7 @@ def validate_assignment( regardless of this parameter. This is mostly a side effect of how things work behind the scenes, but it can be used to explicitly indicate a "raw" modification that is guaranteed to be cheap and - will never error or issue warnings. + will never trigger validation by itself. :getter: :setter: Sets the assignment mode. Raises a :py:class:`.DataFormatError` @@ -182,6 +184,21 @@ def validate_assignment(self, value): # ========================================================================= + @property + def unknown_format(self) -> bool: + """ + A read-only flag which indicates whether or not Draftsman has a full + understanding of the underlying format of the exportable. If this flag + is ``False``, then most validation for this instance is disabled, only + issuing errors/warnings for issues that Draftsman has sufficient + information to diagnose. + + TODO + """ + return self._unknown_format + + # ========================================================================= + @abstractmethod def validate(self): """ @@ -206,17 +223,8 @@ def validate(self): """ # NOTE: Subsequent objects must implement this method and then call this # parent method to cache successful validity - super().__setattr__("_is_valid", True) - - # @abstractmethod - # def inspect(self): - # """ - # Inspects - - # :returns: A :py:class:`.ValidationResult` object, containing any found - # errors or warnings pertaining to this object. - # """ - # return ValidationResult([], []) + # super().__setattr__("_is_valid", True) + pass # pragma: no coverage def to_dict(self, exclude_none: bool = True, exclude_defaults: bool = True) -> dict: return self._root.model_dump( @@ -236,7 +244,7 @@ def to_dict(self, exclude_none: bool = True, exclude_defaults: bool = True) -> d ) @classmethod - def json_schema(cls) -> dict: # pragma: no coverage + def json_schema(cls) -> dict: """ Returns a JSON schema object that correctly validates this object. This schema can be used with any compliant JSON schema validation library to @@ -250,10 +258,7 @@ def json_schema(cls) -> dict: # pragma: no coverage types, ranges, allowed/excluded values, as well as titles and descriptions. """ - # TODO: is this testable? - # TODO: implement a custom schema_generator subclass that strips - # whitespace from description keys, so that they can be formatted more - # properly later + # TODO: should this be tested? return cls.Format.model_json_schema(by_alias=True) # TODO @@ -266,7 +271,7 @@ def json_schema(cls) -> dict: # pragma: no coverage # :returns: A formatted string that can be output to stdout or file. # """ - # return json.dumps(cls.dump_format(), indent=indent) # pragma: no coverage + # return json.dumps(cls.dump_format(), indent=indent) # ========================================================================= diff --git a/draftsman/classes/group.py b/draftsman/classes/group.py index bf1cf16..3fa9815 100644 --- a/draftsman/classes/group.py +++ b/draftsman/classes/group.py @@ -1,7 +1,4 @@ # group.py -# -*- encoding: utf-8 -*- - -from __future__ import unicode_literals from draftsman.classes.association import Association from draftsman.classes.collision_set import CollisionSet @@ -12,12 +9,10 @@ from draftsman.classes.spatial_data_structure import SpatialDataStructure from draftsman.classes.spatial_hashmap import SpatialHashMap from draftsman.classes.transformable import Transformable -from draftsman.classes.vector import Vector +from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.error import ( DraftsmanError, IncorrectBlueprintTypeError, - DataFormatError, - MalformedBlueprintStringError, ) from draftsman.signatures import Connections from draftsman.utils import ( @@ -29,7 +24,7 @@ ) import copy -from typing import Union +from typing import Optional, Union import six @@ -84,14 +79,13 @@ class Group(Transformable, EntityCollection, EntityLike): @reissue_warnings def __init__( self, - id=None, - name="group", - type="group", - position=(0, 0), - entities=[], - string=None, + id: str = None, + name: str = "group", + type: str = "group", + position: Union[Vector, PrimitiveVector] = (0, 0), + entities: Union[list[EntityLike], EntityList] = [], + string: str = None, ): - # type: (str, str, str, Union[dict, list, tuple], list, str) -> None """ TODO """ @@ -166,7 +160,7 @@ def load_from_string(self, blueprint_string: str): elif side in {"Cu0", "Cu1"}: # pragma: no branch connection_points = connections[side] if connection_points is None: - continue + continue # pragma: no coverage for point in connection_points: old = point["entity_id"] - 1 point["entity_id"] = Association(self.entities[old]) @@ -183,7 +177,6 @@ def load_from_string(self, blueprint_string: str): @reissue_warnings def setup(self, **kwargs): - # type: (dict) -> None """ Sets up a Group using a blueprint JSON dict. Currently only reads the ``"entities"`` and ``"schedules"`` keys and loads them into @@ -204,8 +197,7 @@ def setup(self, **kwargs): # ========================================================================= @property - def name(self): - # type: () -> str + def name(self) -> str: """ The name of the Group. Defaults to ``"group"``. Can be specified to any string to aid in organization. For example: @@ -227,8 +219,7 @@ def name(self): return self._name @name.setter - def name(self, value): - # type: (str) -> None + def name(self, value: str): if isinstance(value, six.string_types): self._name = six.text_type(value) else: @@ -259,8 +250,7 @@ def type(self) -> str: return self._type @type.setter - def type(self, value): - # type: (str) -> None + def type(self, value: str): if isinstance(value, six.string_types): self._type = six.text_type(value) else: @@ -269,8 +259,7 @@ def type(self, value): # ========================================================================= @property - def id(self): - # type: () -> str + def id(self) -> Optional[str]: """ The ID of the Group. The most local form of identification between Groups. @@ -284,8 +273,7 @@ def id(self): return self._id @id.setter - def id(self, value): - # type: (str) -> None + def id(self, value: Optional[str]): if value is None or isinstance(value, six.string_types): old_id = getattr(self, "id", None) self._id = six.text_type(value) if value is not None else value @@ -300,8 +288,7 @@ def id(self, value): # ========================================================================= @property - def position(self): - # type: () -> dict + def position(self) -> Vector: """ The position of the Group. Acts as the origin of all the entities contained within, and offsets them on export. @@ -323,8 +310,7 @@ def position(self): return self._position @position.setter - def position(self, value): - # type: (Union[list, dict]) -> None + def position(self, value: Union[Vector, PrimitiveVector]): if self.parent: raise DraftsmanError( "Cannot change position of Group while it's inside a Collection" @@ -342,8 +328,7 @@ def position(self, value): # ========================================================================= @property - def global_position(self): - # type: () -> dict + def global_position(self) -> Vector: """ The "global", or root-most position of the Group. This value is always equivalent to :py:attr:`~.Group.position`, unless the group exists @@ -381,8 +366,7 @@ def global_position(self): # self._collision_box = signatures.AABB.validate(value) @property - def collision_set(self): - # type: () -> CollisionSet + def collision_set(self) -> CollisionSet: """ TODO: is this even a good idea to have in the first place? It seems like having this set be the union of all entities it contains would make the @@ -405,8 +389,7 @@ def collision_set(self): # ========================================================================= @property - def collision_mask(self): - # type: () -> set + def collision_mask(self) -> set[str]: """ The set of all collision layers that this Entity collides with, specified as strings. Defaults to an empty ``set``. Not exported. @@ -421,8 +404,7 @@ def collision_mask(self): return self._collision_mask @collision_mask.setter - def collision_mask(self, value): - # type: (set) -> None + def collision_mask(self, value: Optional[set[str]]): if value is None: self._collision_mask = set() elif isinstance(value, set): @@ -433,8 +415,7 @@ def collision_mask(self, value): # ========================================================================= @property - def tile_width(self): - # type: () -> int + def tile_width(self) -> int: """ The width of the Group's ``collision_box``, rounded up to the nearest tile. Read only. @@ -444,8 +425,7 @@ def tile_width(self): return self._tile_width @tile_width.setter - def tile_width(self, value): - # type: (int) -> None + def tile_width(self, value: int): if isinstance(value, six.integer_types): self._tile_width = value else: @@ -454,8 +434,7 @@ def tile_width(self, value): # ========================================================================= @property - def tile_height(self): - # type: () -> int + def tile_height(self) -> int: """ The width of the Group's ``collision_box``, rounded up to the nearest tile. Read only. @@ -465,8 +444,7 @@ def tile_height(self): return self._tile_height @tile_height.setter - def tile_height(self, value): - # type: (int) -> None + def tile_height(self, value: int): if isinstance(value, six.integer_types): self._tile_height = value else: @@ -477,7 +455,6 @@ def tile_height(self, value): @property def double_grid_aligned(self) -> bool: for entity in self.entities: - print(entity.double_grid_aligned) if entity.double_grid_aligned: return True return False @@ -497,8 +474,7 @@ class named :py:class:`draftsman.classes.EntityList`, which has all the @entities.setter @reissue_warnings - def entities(self, value): - # type: (Union[list[EntityLike], EntityList]) -> None + def entities(self, value: Union[list[EntityLike], EntityList]): self._entity_map.clear() if value is None: @@ -516,8 +492,7 @@ def entities(self, value): # ========================================================================= @property - def entity_map(self): - # type: () -> SpatialDataStructure + def entity_map(self) -> SpatialDataStructure: """ An implementation of :py:class:`.SpatialDataStructure` for ``entities``. Not exported; read only. @@ -526,8 +501,9 @@ def entity_map(self): # ========================================================================= - def on_entity_insert(self, entitylike, merge): - # type: (EntityLike, bool) -> EntityLike + def on_entity_insert( + self, entitylike: EntityLike, merge: bool + ) -> Optional[EntityLike]: """ Callback function for when an ``EntityLike`` is added to this Group's ``entities`` list. Handles the addition of the entity into @@ -552,8 +528,7 @@ def on_entity_insert(self, entitylike, merge): return entitylike - def on_entity_set(self, old_entitylike, new_entitylike): - # type: (EntityLike, EntityLike) -> None + def on_entity_set(self, old_entitylike: EntityLike, new_entitylike: EntityLike): """ Callback function for when an entity is overwritten in a Group's ``entities`` list. Handles the removal of the old ``EntityLike`` from @@ -570,8 +545,7 @@ def on_entity_set(self, old_entitylike, new_entitylike): self.recalculate_area() - def on_entity_remove(self, entitylike): - # type: (EntityLike) -> None + def on_entity_remove(self, entitylike: EntityLike): """ Callback function for when an entity is removed from a Blueprint's ``entities`` list. Handles the removal of the ``EntityLike`` from the @@ -585,8 +559,7 @@ def on_entity_remove(self, entitylike): # ========================================================================= @property - def schedules(self): - # type: () -> list + def schedules(self) -> ScheduleList: """ A list of the Blueprint's train schedules. @@ -612,8 +585,7 @@ def schedules(self): return self._schedules @schedules.setter - def schedules(self, value): - # type: (list) -> None + def schedules(self, value: Union[list, ScheduleList]): if value is None: self._schedules = ScheduleList() elif isinstance(value, ScheduleList): @@ -623,7 +595,7 @@ def schedules(self, value): # ========================================================================= - def get(self): + def get(self) -> list[EntityLike]: """ Gets all the child-most ``Entity`` instances in this ``Group`` and returns them as a "flattened" 1-dimensional list. Offsets all of their @@ -635,7 +607,6 @@ def get(self): return flatten_entities(self.entities) def recalculate_area(self): - # type: () -> None """ Recalculates the dimensions of the area and tile_width and height. Called when an ``EntityLike`` object is altered or removed. @@ -648,8 +619,7 @@ def recalculate_area(self): self._collision_set.get_bounding_box() ) - def get_world_bounding_box(self): - # type: () -> AABB + def get_world_bounding_box(self) -> AABB: """ TODO """ @@ -666,26 +636,14 @@ def get_world_bounding_box(self): return bounding_box - def inspect(self): - # type: () -> list[Exception] - issues = [] - - for entity in self.entities: - issues += entity.inspect() - - return issues - - def mergable_with(self, other): - # type: (Group) -> bool + def mergable_with(self, other: "Group") -> bool: # For now, we assume that Groups themselves are not mergable # return type(self) == type(other) and self.id == other.id return False - def merge(self, other): - # type: (Group) -> None + def merge(self, other: "Group"): # For now, we assume that Groups themselves are not mergable # TODO: is this the case? - # print("group.merge") return # Do nothing def __str__(self) -> str: # pragma: no coverage @@ -695,8 +653,7 @@ def __str__(self) -> str: # pragma: no coverage def __repr__(self) -> str: # pragma: no coverage return "{}".format(self.entities.data) - def __deepcopy__(self, memo): - # type: (dict) -> Group + def __deepcopy__(self, memo: dict) -> "Group": """ Creates a deepcopy of a :py:class:`.Group` and it's contents. Preserves all Entities and Associations within the copied group, *except* for diff --git a/draftsman/classes/mixins/burner_energy_source.py b/draftsman/classes/mixins/burner_energy_source.py index 4200f2f..31bebe6 100644 --- a/draftsman/classes/mixins/burner_energy_source.py +++ b/draftsman/classes/mixins/burner_energy_source.py @@ -1,8 +1,8 @@ # burner_energy_source.py from draftsman.constants import ValidationMode -from draftsman.signatures import uint16, uint32 -from draftsman.warning import ItemCapacityWarning, ItemLimitationWarning +from draftsman.signatures import ItemName, uint16, uint32 +from draftsman.warning import FuelCapacityWarning, FuelLimitationWarning from draftsman.data import entities, items @@ -32,7 +32,7 @@ def ensure_fuel_type_valid( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "BurnerEnergySourceMixin" = info.context["object"] @@ -52,14 +52,14 @@ def ensure_fuel_type_valid( ) ) - issue = ItemLimitationWarning( - "Cannot add fuel item '{}' to '{}';{}".format( - item, entity.name, context_string + warning_list.append( + FuelLimitationWarning( + "Cannot add fuel item '{}' to '{}';{}".format( + item, entity.name, context_string + ) ) ) - warning_list.append(issue) - return value @field_validator("items", check_fields=False) @@ -74,7 +74,7 @@ def ensure_fuel_doesnt_exceed_slots( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "BurnerEnergySourceMixin" = info.context["object"] @@ -83,10 +83,10 @@ def ensure_fuel_doesnt_exceed_slots( if entity.total_fuel_slots is None: # entity not recognized return value - if entity.occupied_fuel_slots > entity.total_fuel_slots: - issue = ItemCapacityWarning( + if entity.fuel_slots_occupied > entity.total_fuel_slots: + issue = FuelCapacityWarning( "Amount of slots occupied by fuel items ({}) exceeds available fuel slots ({}) for entity '{}'".format( - entity.occupied_fuel_slots, entity.total_fuel_slots, entity.name + entity.fuel_slots_occupied, entity.total_fuel_slots, entity.name ) ) @@ -105,10 +105,11 @@ def __init__(self, name: str, similar_entities: list[str], **kwargs): if name not in _valid_fuel_items: if name in entities.raw: energy_source = self.input_energy_source - print("energy_source:", energy_source) if energy_source is not None: if "fuel_categories" in energy_source: - fuel_categories = energy_source["fuel_categories"] + fuel_categories = energy_source[ + "fuel_categories" + ] # pragma: no coverage else: fuel_categories = [ energy_source.get("fuel_category", "chemical") @@ -125,7 +126,7 @@ def __init__(self, name: str, similar_entities: list[str], **kwargs): # ========================================================================= @property - def input_energy_source(self) -> dict: + def input_energy_source(self) -> Optional[dict]: """ TODO """ @@ -140,15 +141,12 @@ def input_energy_source(self) -> dict: # ========================================================================= @property - def total_fuel_slots(self) -> uint16: + def total_fuel_slots(self) -> Optional[uint16]: """ Gets the total number of fuel input slots that this entity can hold. Returns ``None`` if the name of this entity is not recognized by Draftsman. Not exported; read only. """ - # return entities.raw.get(self.name, { - # "energy_source": {"fuel_inventory_size": None} - # })["energy_source"]["fuel_inventory_size"] energy_source = self.input_energy_source if energy_source is not None: return energy_source.get("fuel_inventory_size", None) @@ -158,7 +156,7 @@ def total_fuel_slots(self) -> uint16: # ========================================================================= @property - def occupied_fuel_slots(self) -> int: + def fuel_slots_occupied(self) -> int: """ Gets the total number of fuel slots currently occupied by fuel item requests. Items not recognized by Draftsman are ignored from the @@ -173,7 +171,7 @@ def occupied_fuel_slots(self) -> int: # ========================================================================= @property - def allowed_fuel_items(self) -> set[str]: + def allowed_fuel_items(self) -> Optional[set[str]]: """ A set of strings, each one a valid item that can be used as a fuel source to power this furnace. If this furnace does not burn items to @@ -186,11 +184,11 @@ def allowed_fuel_items(self) -> set[str]: # ========================================================================= @property - def fuel_items(self) -> dict[str, uint32]: # TODO: ItemID + def fuel_items(self) -> dict[ItemName, uint32]: """ The subset of :py:attr:`.items` where each key is a valid item fuel source that can be consumed by this entity. Returns an empty dict if none of the keys of ``items`` are known as valid module names. Not exported; read only. """ - return {k: v for k, v in self.items.items() if k in self.allowed_fuel_items} + return {k: v for k, v in self.items.items() if k in items.all_fuel_items} diff --git a/draftsman/classes/mixins/circuit_condition.py b/draftsman/classes/mixins/circuit_condition.py index 48188d7..2bdfdc9 100644 --- a/draftsman/classes/mixins/circuit_condition.py +++ b/draftsman/classes/mixins/circuit_condition.py @@ -3,7 +3,7 @@ from draftsman.signatures import Condition, SignalID, int32 from pydantic import BaseModel, Field -from typing import Literal, Optional, Union +from typing import Any, Literal, Optional, Union class CircuitConditionMixin: # (ControlBehaviorMixin) diff --git a/draftsman/classes/mixins/circuit_connectable.py b/draftsman/classes/mixins/circuit_connectable.py index a1862da..28e4f51 100644 --- a/draftsman/classes/mixins/circuit_connectable.py +++ b/draftsman/classes/mixins/circuit_connectable.py @@ -5,8 +5,13 @@ from draftsman.data import entities from draftsman.signatures import DraftsmanBaseModel, Connections -from pydantic import Field -from typing import Optional +from pydantic import ( + Field, + ValidationInfo, + ValidatorFunctionWrapHandler, + field_validator, +) +from typing import Any, Optional class CircuitConnectableMixin: @@ -145,8 +150,8 @@ def merge_circuit_connection(self, side, color, point, other): continue # if other.connections[side] is not None: if side in {"1", "2"}: - if self.connections[side] is None: - self.connections[side] = Connections.CircuitConnections() + # if self.connections[side] is None: + # self.connections[side] = Connections.CircuitConnections() for color, _ in other.connections[side]: print(color) if other.connections[side][color] is None: @@ -166,6 +171,12 @@ def merge_circuit_connection(self, side, color, point, other): "Cannot merge power switches (yet); see function for details" ) + def to_dict(self, exclude_none: bool = True, exclude_defaults: bool = True) -> dict: + result = super().to_dict(exclude_none, exclude_defaults) + if "connections" in result and result["connections"] == {}: + del result["connections"] + return result + # ========================================================================= def __eq__(self, other) -> bool: diff --git a/draftsman/classes/mixins/circuit_read_contents.py b/draftsman/classes/mixins/circuit_read_contents.py index f691449..2af69c0 100644 --- a/draftsman/classes/mixins/circuit_read_contents.py +++ b/draftsman/classes/mixins/circuit_read_contents.py @@ -38,7 +38,7 @@ class Format(BaseModel): pass @property - def read_contents(self) -> bool: + def read_contents(self) -> Optional[bool]: """ Whether or not this Entity is set to read it's contents to a circuit network. @@ -53,24 +53,23 @@ def read_contents(self) -> bool: return self.control_behavior.circuit_read_hand_contents @read_contents.setter - def read_contents(self, value: bool): - # type: (bool) -> None + def read_contents(self, value: Optional[bool]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_read_hand_contents", value) - - self.control_behavior.circuit_read_hand_contents = value - - # if value is None: - # self.control_behavior.pop("circuit_read_hand_contents", None) - # elif isinstance(value, bool): - # self.control_behavior["circuit_read_hand_contents"] = value - # else: - # raise TypeError("'read_contents' must be a bool or None") + result = attempt_and_reissue( + self, + self.Format.ControlBehavior, + self.control_behavior, + "circuit_read_hand_contents", + value, + ) + self.control_behavior.circuit_read_hand_contents = result + else: + self.control_behavior.circuit_read_hand_contents = value # ========================================================================= @property - def read_mode(self) -> ReadMode: + def read_mode(self) -> Optional[ReadMode]: """ The mode in which the contents of the Entity should be read. Either ``ReadMode.PULSE`` or ``ReadMode.HOLD``. @@ -85,13 +84,15 @@ def read_mode(self) -> ReadMode: return self.control_behavior.circuit_contents_read_mode @read_mode.setter - def read_mode(self, value: ReadMode): + def read_mode(self, value: Optional[ReadMode]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_contents_read_mode", value) - - self.control_behavior.circuit_contents_read_mode = value - - # if value is None: - # self.control_behavior.pop("circuit_contents_read_mode", None) - # else: - # self.control_behavior["circuit_contents_read_mode"] = ReadMode(value) + result = attempt_and_reissue( + self, + self.Format.ControlBehavior, + self.control_behavior, + "circuit_contents_read_mode", + value, + ) + self.control_behavior.circuit_contents_read_mode = result + else: + self.control_behavior.circuit_contents_read_mode = value diff --git a/draftsman/classes/mixins/circuit_read_hand.py b/draftsman/classes/mixins/circuit_read_hand.py index fd2de60..824cc86 100644 --- a/draftsman/classes/mixins/circuit_read_hand.py +++ b/draftsman/classes/mixins/circuit_read_hand.py @@ -21,13 +21,13 @@ class CircuitReadHandMixin: # (ControlBehaviorMixin) class ControlFormat(BaseModel): circuit_read_hand_contents: Optional[bool] = Field( - None, + None, # TODO: default = False description=""" Whether or not to read the contents of this inserter's hand. """, ) circuit_hand_read_mode: Optional[ReadMode] = Field( - None, + None, # TODO: default = ReadMode.PULSE description=""" Whether to hold or pulse the inserter's held items, if 'circuit_read_hand_contents' is true. @@ -38,7 +38,7 @@ class Format(BaseModel): pass @property - def read_hand_contents(self) -> bool: + def read_hand_contents(self) -> Optional[bool]: """ Whether or not this Entity is set to read the contents of it's hand to a circuit network. @@ -54,24 +54,23 @@ def read_hand_contents(self) -> bool: return self.control_behavior.circuit_read_hand_contents @read_hand_contents.setter - def read_hand_contents(self, value): - # type: (bool) -> None + def read_hand_contents(self, value: Optional[bool]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_read_hand_contents", value) - - self.control_behavior.circuit_read_hand_contents = value - - # if value is None: - # self.control_behavior.pop("circuit_read_hand_contents", None) - # elif isinstance(value, bool): - # self.control_behavior["circuit_read_hand_contents"] = value - # else: - # raise TypeError("'read_hand_contents' must be a bool or None") + result = attempt_and_reissue( + self, + self.Format.ControlBehavior, + self.control_behavior, + "circuit_read_hand_contents", + value, + ) + self.control_behavior.circuit_read_hand_contents = result + else: + self.control_behavior.circuit_read_hand_contents = value # ========================================================================= @property - def read_mode(self) -> ReadMode: + def read_mode(self) -> Optional[ReadMode]: """ The mode in which the contents of the Entity should be read. Either ``ReadMode.PULSE`` or ``ReadMode.HOLD``. @@ -86,13 +85,15 @@ def read_mode(self) -> ReadMode: return self.control_behavior.circuit_hand_read_mode @read_mode.setter - def read_mode(self, value: ReadMode): + def read_mode(self, value: Optional[ReadMode]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_contents_read_mode", value) - - self.control_behavior.circuit_contents_read_mode = value - - # if value is None: - # self.control_behavior.pop("circuit_hand_read_mode", None) - # else: - # self.control_behavior["circuit_hand_read_mode"] = ReadMode(value) + result = attempt_and_reissue( + self, + self.Format.ControlBehavior, + self.control_behavior, + "circuit_hand_read_mode", + value, + ) + self.control_behavior.circuit_hand_read_mode = result + else: + self.control_behavior.circuit_hand_read_mode = value diff --git a/draftsman/classes/mixins/color.py b/draftsman/classes/mixins/color.py index 9d7630c..2316d69 100644 --- a/draftsman/classes/mixins/color.py +++ b/draftsman/classes/mixins/color.py @@ -3,8 +3,14 @@ from draftsman.classes.exportable import attempt_and_reissue from draftsman.signatures import Color -from pydantic import BaseModel, Field -from typing import Optional, Union +from pydantic import ( + BaseModel, + Field, + ValidationInfo, + ValidatorFunctionWrapHandler, + field_validator, +) +from typing import Any, Optional, Union from typing import TYPE_CHECKING diff --git a/draftsman/classes/mixins/control_behavior.py b/draftsman/classes/mixins/control_behavior.py index d7a3e8b..940a6fa 100644 --- a/draftsman/classes/mixins/control_behavior.py +++ b/draftsman/classes/mixins/control_behavior.py @@ -1,7 +1,4 @@ # control_behavior.py -# -*- encoding: utf-8 -*- - -from __future__ import unicode_literals from draftsman.classes.exportable import attempt_and_reissue from draftsman.error import DataFormatError @@ -12,9 +9,14 @@ int32, ) -import copy -from pydantic import validate_call -from typing import Literal, Union +from pydantic import ( + ValidationInfo, + ValidationError, + ValidatorFunctionWrapHandler, + field_validator, + validate_call, +) +from typing import Any, Literal, Union class ControlBehaviorMixin: @@ -56,9 +58,9 @@ def __init__(self, name: str, similar_entities: list[str], **kwargs): super().__init__(name, similar_entities, **kwargs) # Have to do a bit of forward-lookahead to grab the correct control_behavior - self.control_behavior = kwargs.get( - "control_behavior", type(self).Format.ControlBehavior() - ) + # self.control_behavior = kwargs.get( + # "control_behavior", type(self).Format.ControlBehavior() + # ) # ========================================================================= @@ -94,6 +96,12 @@ def merge(self, other): self.control_behavior = other.control_behavior + def to_dict(self, exclude_none: bool = True, exclude_defaults: bool = True) -> dict: + result = super().to_dict(exclude_none, exclude_defaults) + if "control_behavior" in result and result["control_behavior"] == {}: + del result["control_behavior"] + return result + # ========================================================================= @validate_call @@ -119,43 +127,40 @@ def _set_condition( ``cmp`` is not a valid operation, or if ``b`` is neither a valid signal name nor a constant. """ - # Check the inputs - # try: - # a = signatures.SIGNAL_ID_OR_NONE.validate(a) - # if a is not None: - # a = signatures.SignalID(a) - # cmp = signatures.Comparator(root=cmp) - # b = signatures.SIGNAL_ID_OR_CONSTANT.validate(b) - # except ValidationError as e: - # raise DataFormatError(e) from None - - # self.control_behavior[condition_name] = {} - setattr(self.control_behavior, condition_name, Condition()) - # condition = self.control_behavior[condition_name] - condition: Condition = getattr(self.control_behavior, condition_name) + new_condition = Condition() # A # if a is None: # condition.pop("first_signal", None) # else: # condition["first_signal"] = a - condition.first_signal = a - condition.comparator = cmp + new_condition.first_signal = a + new_condition.comparator = cmp # B (should never be None) - if isinstance(b, dict): - condition.second_signal = b - condition.constant = None - else: # int - condition.constant = b - condition.second_signal = None + if isinstance(b, int): + new_condition.constant = b + else: + new_condition.second_signal = b # TODO: we need to figure out a way to dirty an entity even if we only # modify it's sub-attributes like control behavior; the above function # does not reset `is_valid` even though it should # We manually set it below, but this is not sufficient for cases where # the user themselves modify subattributes; FIXME - self._is_valid = False + # self._is_valid = False + + # Check if the condition is valid, and raise/warn if not + result = attempt_and_reissue( + self, + type(self).Format.ControlBehavior, + self.control_behavior, + condition_name, + new_condition, + ) + + # Success, so assign + self.control_behavior[condition_name] = result # ========================================================================= diff --git a/draftsman/classes/mixins/directional.py b/draftsman/classes/mixins/directional.py index 74795a2..ce551ba 100644 --- a/draftsman/classes/mixins/directional.py +++ b/draftsman/classes/mixins/directional.py @@ -3,12 +3,17 @@ from draftsman.classes.collision_set import CollisionSet from draftsman.classes.exportable import attempt_and_reissue from draftsman.signatures import IntPosition -from draftsman.constants import Direction +from draftsman.constants import Direction, ValidationMode from draftsman.error import DraftsmanError from draftsman.warning import DirectionWarning -from pydantic import BaseModel, Field, ValidationInfo, field_validator -from typing import Optional, Union +from pydantic import ( + BaseModel, + Field, + ValidationInfo, + field_validator, +) +from typing import Optional from typing import TYPE_CHECKING @@ -45,7 +50,7 @@ class Format(BaseModel): def ensure_4_way(cls, input: Optional[Direction], info: ValidationInfo): if not info.context: return input - if info.context["mode"] == "minimum": + if info.context["mode"] <= ValidationMode.MINIMUM: return input warning_list: list = info.context["warning_list"] @@ -55,18 +60,13 @@ def ensure_4_way(cls, input: Optional[Direction], info: ValidationInfo): # Default to a known orientation output = Direction(int(input / 2) * 2) - if info.context["mode"] == "pedantic": - raise ValueError( - "'{}' only has 4-way rotation".format(type(entity).__name__) - ) - else: - warning_list.append( - DirectionWarning( - "'{}' only has 4-way rotation; defaulting to {}".format( - type(entity).__name__, output - ), - ) + warning_list.append( + DirectionWarning( + "'{}' only has 4-way rotation; defaulting to {}".format( + type(entity).__name__, output + ), ) + ) return output else: @@ -76,7 +76,7 @@ def __init__( self, name: str, similar_entities: list[str], - tile_position: IntPosition = [0, 0], + tile_position: IntPosition = (0, 0), **kwargs ): self._root: __class__.Format @@ -107,9 +107,9 @@ def __init__( if super().collision_set: _rotated_collision_sets[name] = {} for i in {0, 2, 4, 6}: - _rotated_collision_sets[name][ + _rotated_collision_sets[name][i] = super().collision_set.rotate( i - ] = super().collision_set.rotate(i) + ) else: _rotated_collision_sets[name] = {} for i in {0, 2, 4, 6}: @@ -153,7 +153,7 @@ def static_collision_set(self) -> Optional[CollisionSet]: @property def collision_set(self) -> Optional[CollisionSet]: return _rotated_collision_sets.get(self.name, {}).get(self.direction, None) - + # ========================================================================= @property diff --git a/draftsman/classes/mixins/double_grid_aligned.py b/draftsman/classes/mixins/double_grid_aligned.py index c0650a0..93c1b48 100644 --- a/draftsman/classes/mixins/double_grid_aligned.py +++ b/draftsman/classes/mixins/double_grid_aligned.py @@ -1,6 +1,7 @@ # double_grid_aligned.py from draftsman.classes.vector import Vector +from draftsman.constants import ValidationMode from draftsman.signatures import FloatPosition from draftsman.warning import GridAlignmentWarning @@ -22,7 +23,7 @@ class Format(BaseModel): def ensure_double_grid_aligned(cls, input: FloatPosition, info: ValidationInfo): if not info.context: return input - if info.context["mode"] == "minimum": + if info.context["mode"] <= ValidationMode.MINIMUM: return input warning_list: list = info.context["warning_list"] @@ -32,17 +33,14 @@ def ensure_double_grid_aligned(cls, input: FloatPosition, info: ValidationInfo): math.floor(entity.tile_position.x / 2) * 2, math.floor(entity.tile_position.y / 2) * 2, ) - issue = GridAlignmentWarning( - "Double-grid aligned entity is not placed along chunk grid; " - "entity's tile position will be cast from {} to {} when " - "imported".format(entity.tile_position, cast_position) + warning_list.append( + GridAlignmentWarning( + "Double-grid aligned entity is not placed along chunk grid; " + "entity's tile position will be cast from {} to {} when " + "imported".format(entity.tile_position, cast_position) + ) ) - if info.context["mode"] == "pedantic": - raise ValueError(issue) from None - else: - warning_list.append(issue) - return input def __init__(self, name: str, similar_entities: list[str], **kwargs): diff --git a/draftsman/classes/mixins/eight_way_directional.py b/draftsman/classes/mixins/eight_way_directional.py index 6d2422e..f2aa2c6 100644 --- a/draftsman/classes/mixins/eight_way_directional.py +++ b/draftsman/classes/mixins/eight_way_directional.py @@ -4,8 +4,16 @@ from draftsman.constants import Direction from draftsman.signatures import IntPosition, uint8 -from pydantic import BaseModel, Field, PrivateAttr, field_serializer -from typing import Optional +from pydantic import ( + BaseModel, + Field, + PrivateAttr, + ValidationInfo, + ValidatorFunctionWrapHandler, + field_serializer, + field_validator, +) +from typing import Any, Optional from typing import TYPE_CHECKING @@ -42,7 +50,7 @@ def __init__( self, name: str, similar_entities: list[str], - tile_position: IntPosition = [0, 0], + tile_position: IntPosition = (0, 0), **kwargs ): self._root: __class__.Format @@ -112,7 +120,7 @@ def square(self) -> bool: # ========================================================================= @property - def direction(self) -> Optional[Direction]: + def direction(self) -> Direction: """ The direction that the Entity is facing. An Entity's "front" is usually the direction of it's outputs, if it has any. @@ -135,7 +143,7 @@ def direction(self) -> Optional[Direction]: return self._root.direction @direction.setter - def direction(self, value: Optional[Direction]): + def direction(self, value: Direction): if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "direction", value @@ -157,25 +165,24 @@ def direction(self, value: Optional[Direction]): # if self._root.direction in {2, 3, 6, 7}: # self._tile_width = self._static_tile_height # self._tile_height = self._static_tile_width - # self._collision_box[0] = [ - # self.static_collision_box[0][1], - # self.static_collision_box[0][0], - # ] - # self._collision_box[1] = [ - # self.static_collision_box[1][1], - # self.static_collision_box[1][0], - # ] + # self._collision_box[0] = [ + # self.static_collision_box[0][1], + # self.static_collision_box[0][0], + # ] + # self._collision_box[1] = [ + # self.static_collision_box[1][1], + # self.static_collision_box[1][0], + # ] # else: # self._tile_width = self._static_tile_width # self._tile_height = self._static_tile_height - # self._collision_box = self.static_collision_box + # self._collision_box = self.static_collision_box # self._collision_set = self._collision_set_rotation.get( # self._root.direction, None # ) # TODO: overwrite tile_width/height properties instead - print(self._root.direction) if self._root.direction in {2, 3, 6, 7}: print(self._static_tile_width, self._static_tile_height) self._tile_width = self._static_tile_height @@ -190,8 +197,7 @@ def direction(self, value: Optional[Direction]): # ========================================================================= - def mergable_with(self, other): - # type: (Entity) -> bool + def mergable_with(self, other: "EightWayDirectionalMixin") -> bool: base_mergable = super().mergable_with(other) return base_mergable and self.direction == other.direction diff --git a/draftsman/classes/mixins/enable_disable.py b/draftsman/classes/mixins/enable_disable.py index 2e2fddc..0e39bcd 100644 --- a/draftsman/classes/mixins/enable_disable.py +++ b/draftsman/classes/mixins/enable_disable.py @@ -28,7 +28,7 @@ class Format(BaseModel): pass @property - def enable_disable(self) -> bool: + def enable_disable(self) -> Optional[bool]: """ Whether or not the machine enables its operation based on a circuit condition. Only used on entities that have multiple operation states, @@ -46,8 +46,15 @@ def enable_disable(self) -> bool: return self.control_behavior.circuit_enable_disable @enable_disable.setter - def enable_disable(self, value: bool): + def enable_disable(self, value: Optional[bool]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_enable_disable", value) - - self.control_behavior.circuit_enable_disable = value + result = attempt_and_reissue( + self, + type(self).Format.ControlBehavior, + self.control_behavior, + "circuit_enable_disable", + value, + ) + self.control_behavior.circuit_enable_disable = result + else: + self.control_behavior.circuit_enable_disable = value diff --git a/draftsman/classes/mixins/filters.py b/draftsman/classes/mixins/filters.py index 1c1444c..f66310f 100644 --- a/draftsman/classes/mixins/filters.py +++ b/draftsman/classes/mixins/filters.py @@ -3,11 +3,10 @@ from draftsman.classes.exportable import attempt_and_reissue from draftsman.data import items, entities from draftsman.error import InvalidItemError, DataFormatError -from draftsman.signatures import DraftsmanBaseModel, Filters, SignalID, int64 +from draftsman.signatures import DraftsmanBaseModel, FilterEntry, ItemName, int64 -from pydantic import Field, ValidationError, ValidationInfo, validate_call -from typing import Optional -import six +from pydantic import Field, ValidationError, ValidationInfo, field_validator +from typing import Any, Optional class FiltersMixin: @@ -16,13 +15,27 @@ class FiltersMixin: """ class Format(DraftsmanBaseModel): - filters: Optional[Filters] = Field( - Filters(), + filters: Optional[list[FilterEntry]] = Field( + [], description=""" Any item filters that this inserter or loader has. """, ) + @field_validator("filters", mode="before") + @classmethod + def normalize_validate(cls, value: Any): + if isinstance(value, (list, tuple)): + result = [] + for i, entry in enumerate(value): + if isinstance(entry, str): + result.append({"index": i + 1, "name": entry}) + else: + result.append(entry) + return result + else: + return value + def __init__(self, name: str, similar_entities: list[str], **kwargs): self._root: __class__.Format @@ -47,14 +60,14 @@ def filter_count(self) -> Optional[int]: ) @property - def filters(self) -> Filters: + def filters(self) -> Optional[list[FilterEntry]]: """ TODO """ return self._root.filters @filters.setter - def filters(self, value: Filters): + def filters(self, value: Optional[list[FilterEntry]]): if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "filters", value @@ -65,7 +78,7 @@ def filters(self, value: Filters): # ========================================================================= - def set_item_filter(self, index: int64, item: str): # TODO: SignalName + def set_item_filter(self, index: int64, item: ItemName): """ Sets one of the item filters of the Entity. `index` in this function is in 0-based notation. @@ -77,91 +90,75 @@ def set_item_filter(self, index: int64, item: str): # TODO: SignalName to ``filter_count``. :exception InvalidItemError: If ``item`` is not a valid item name. """ - # Check if index is ouside the range of the max filter slots - if index >= self.filter_count: - raise IndexError( - "Index {} exceeds the maximum number of filter slots for this " - "entity ({})".format(index, self.filter_count) - ) - - if item is not None and item not in items.raw: - raise InvalidItemError("'{}'".format(item)) - - if self.filters is None: - self.filters = Filters() - - for i in range(len(self.filters.root)): - filter = self.filters.root[i] + if item is not None: + try: + new_entry = FilterEntry(index=index, name=item) + new_entry.index += 1 + except ValidationError as e: + raise DataFormatError(e) from None + + new_filters = self.filters if self.filters is not None else [] + + found_index = None + for i in range(len(new_filters)): + filter = new_filters[i] if filter["index"] == index + 1: if item is None: - del self.filters.root[i] + del new_filters[i] else: filter["name"] = item - return - - # Otherwise its unique; add to list - self.filters.root.append(Filters.FilterEntry(index=index + 1, name=item)) - - # def set_item_filters(self, filters): # TODO: remove(?) - # # type: (list) -> None - # """ - # Sets all of the item filters of the Entity. - - # ``filters`` can be either of the following 2 formats:: - - # [{"index": int, "name": item_name_1}, ...] - # # Or - # [item_name_1, item_name_2, ...] - - # With the first format, the "index" key is in 1-based notation. - # With the second format, the index of each item is set to it's position - # in the list. ``filters`` can also be ``None``, which will wipe all item - # filters that the Entity has. - - # :param filters: The item filters to give the Entity. - - # :exception DataFormatError: If the ``filters`` argument does not match - # the specification above. - # :exception IndexError: If the index of one of the entries exceeds or - # equals the ``filter_count`` of the Entity. - # :exception InvalidItemError: If the item name of one of the entries is - # not valid. - # """ - # if filters is None: - # self.filters = None - # return - - # # Normalize to standard internal format - # try: - # filters = Filters(filters).model_dump( - # by_alias=True, exclude_none=True, exclude_defaults=True - # ) - # except ValidationError as e: - # six.raise_from(DataFormatError(e), None) - - # # Make sure the items are items and indices are within standards - # for item in filters: - # if item["index"] > self.filter_count: - # raise IndexError( - # "Index {} exceeds the maximum number of filter slots for this " - # "entity ({})".format(item["index"], self.filter_count) - # ) - # if item["name"] not in items.raw: - # raise InvalidItemError("'{}'".format(item)) - - # for item in filters: - # self.set_item_filter(item["index"] - 1, item["name"]) + found_index = i + break + + if found_index is None: + new_filters.append(new_entry) + + result = attempt_and_reissue( + self, __class__.Format, self._root, "filters", new_filters + ) + self.filters = result + + def set_item_filters(self, *filters: Optional[list[FilterEntry]]): + """ + Sets all of the item filters of the Entity. + + ``filters`` can be either of the following 2 formats:: + + [{"index": int, "name": item_name_1}, ...] + # Or + [item_name_1, item_name_2, ...] + + With the first format, the "index" key is in 1-based notation. + With the second format, the index of each item is set to it's position + in the list. ``filters`` can also be ``None``, which will wipe all item + filters that the Entity has. + + :param filters: The item filters to give the Entity. + + :exception DataFormatError: If the ``filters`` argument does not match + the specification above. + :exception IndexError: If the index of one of the entries exceeds or + equals the ``filter_count`` of the Entity. + :exception InvalidItemError: If the item name of one of the entries is + not valid. + """ + # Passing None into the function wraps it in a tuple, which we undo here + if len(filters) == 1 and filters[0] is None: + filters = None + + result = attempt_and_reissue( + self, type(self).Format, self._root, "filters", filters + ) + self._root.filters = result # ========================================================================= - def merge(self, other): + def merge(self, other: "FiltersMixin"): super().merge(other) self.filters = other.filters - # for item in other.filters: - # self.set_item_filter(item["index"] - 1, item["name"]) # ========================================================================= - def __eq__(self, other) -> bool: + def __eq__(self, other: "FiltersMixin") -> bool: return super().__eq__(other) and self.filters == other.filters diff --git a/draftsman/classes/mixins/input_ingredients.py b/draftsman/classes/mixins/input_ingredients.py index bb36eaa..f660a18 100644 --- a/draftsman/classes/mixins/input_ingredients.py +++ b/draftsman/classes/mixins/input_ingredients.py @@ -21,7 +21,7 @@ def ensure_in_allowed_ingredients( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "InputIngredientsMixin" = info.context["object"] @@ -35,14 +35,14 @@ def ensure_in_allowed_ingredients( if item in entity.allowed_modules: continue if item not in entity.allowed_input_ingredients: - issue = ItemLimitationWarning( - "Cannot request item '{}' to '{}'; this recipe cannot consume it".format( - item, entity.name + warning_list.append( + ItemLimitationWarning( + "Cannot request item '{}' to '{}'; this recipe cannot consume it".format( + item, entity.name + ) ) ) - warning_list.append(issue) - return value # @field_validator("items", check_fields=False) diff --git a/draftsman/classes/mixins/inventory.py b/draftsman/classes/mixins/inventory.py index d570de6..e99782d 100644 --- a/draftsman/classes/mixins/inventory.py +++ b/draftsman/classes/mixins/inventory.py @@ -5,6 +5,7 @@ from draftsman.data import entities, items from draftsman.classes.exportable import attempt_and_reissue +from draftsman.constants import ValidationMode from draftsman.signatures import ( DraftsmanBaseModel, ensure_bar_less_than_inventory_size, @@ -45,18 +46,13 @@ def ensure_inventory_bar_enabled( ): if not info.context or bar is None: return bar - if info.context["mode"] == "minimum": # TODO: enum + if info.context["mode"] <= ValidationMode.MINIMUM: return bar warning_list: list = info.context["warning_list"] entity = info.context["object"] if not entity.inventory_bar_enabled: - issue = BarWarning("This entity does not have bar control") - - if info.context["mode"] == "pedantic": - raise issue - else: - warning_list.append(issue) + warning_list.append(BarWarning("This entity does not have bar control")) return bar @@ -97,12 +93,14 @@ def inventory_bar_enabled(self) -> bool: """ Whether or not this Entity has its inventory limiting bar enabled. Equivalent to the ``"enable_inventory_bar"`` key in Factorio's - ``data.raw``, or ``True`` if not present. Returns ``None`` if this + ``data.raw``, or ``True`` if not present. Returns ``None`` if this entity is not recognized by Draftsman. Not exported; read only. :type: ``bool`` """ - return entities.raw.get(self.name, {"enable_inventory_bar": None}).get("enable_inventory_bar", True) + return entities.raw.get(self.name, {"enable_inventory_bar": None}).get( + "enable_inventory_bar", True + ) # ========================================================================= diff --git a/draftsman/classes/mixins/inventory_filter.py b/draftsman/classes/mixins/inventory_filter.py index 59e3e89..4dba1bd 100644 --- a/draftsman/classes/mixins/inventory_filter.py +++ b/draftsman/classes/mixins/inventory_filter.py @@ -7,13 +7,24 @@ InvalidItemError, DataFormatError, ) -from draftsman.signatures import InventoryFilters, Filters, uint16 -from draftsman.warning import IndexWarning +from draftsman.signatures import ( + DraftsmanBaseModel, + FilterEntry, + ItemName, + int64, + uint16, + ensure_bar_less_than_inventory_size, +) -from pydantic import BaseModel, Field, ValidationError, validate_call -import six -import warnings -from typing import Optional +from pydantic import ( + BaseModel, + Field, + ValidationError, + ValidationInfo, + validate_call, + field_validator, +) +from typing import Any, Optional from typing import TYPE_CHECKING @@ -27,6 +38,41 @@ class InventoryFilterMixin: """ class Format(BaseModel): + class InventoryFilters(DraftsmanBaseModel): + filters: Optional[list[FilterEntry]] = Field( + None, + description=""" + Any reserved item filter slots in the container's inventory. + """, + ) + bar: Optional[uint16] = Field( + None, + description=""" + Limiting bar on this container's inventory. + """, + ) + + @field_validator("filters", mode="before") + @classmethod + def normalize_validate(cls, value: Any): + if isinstance(value, (list, tuple)): + result = [] + for i, entry in enumerate(value): + if isinstance(entry, str): + result.append({"index": i + 1, "name": entry}) + else: + result.append(entry) + return result + else: + return value + + @field_validator("bar") + @classmethod + def ensure_less_than_inventory_size( + cls, bar: Optional[uint16], info: ValidationInfo + ): + return ensure_bar_less_than_inventory_size(cls, bar, info) + inventory: Optional[InventoryFilters] = Field( InventoryFilters(), description=""" @@ -41,13 +87,12 @@ def __init__(self, name: str, similar_entities: list[str], **kwargs): super().__init__(name, similar_entities, **kwargs) - self.inventory = kwargs.get("inventory", InventoryFilters()) + self.inventory = kwargs.get("inventory", self.Format.InventoryFilters()) # ========================================================================= @property - def inventory_size(self): - # type: () -> int + def inventory_size(self) -> Optional[uint16]: """ The number of inventory slots that this Entity has. Equivalent to the ``"inventory_size"`` key in Factorio's ``data.raw``. Returns ``None`` if @@ -61,7 +106,7 @@ def inventory_size(self): # ========================================================================= @property - def inventory(self) -> InventoryFilters: + def inventory(self) -> Format.InventoryFilters: """ Inventory filter object. Contains the filter information under the ``"filters"`` key and the inventory limiting bar under the ``"bar"`` key. @@ -87,21 +132,21 @@ def inventory(self) -> InventoryFilters: return self._root.inventory @inventory.setter - def inventory(self, value: InventoryFilters): + def inventory(self, value: Format.InventoryFilters): if self.validate_assignment: value = attempt_and_reissue( self, type(self).Format, self._root, "inventory", value ) if value is None: - self._root.inventory = InventoryFilters() + self._root.inventory = __class__.Format.InventoryFilters() else: self._root.inventory = value # ========================================================================= @property - def filter_count(self) -> int: + def filter_count(self) -> Optional[uint16]: """ The number of filter slots that this entity has. In this case, is equivalent to the number of inventory slots of the CargoWagon. Returns @@ -113,18 +158,25 @@ def filter_count(self) -> int: # ========================================================================= @property - def filters(self) -> Filters: + def filters(self) -> Optional[list[FilterEntry]]: """ TODO """ return self.inventory.filters @filters.setter - def filters(self, value: Filters): + def filters(self, value: Optional[list[FilterEntry]]): if self.validate_assignment: - attempt_and_reissue(self, "filters", value) - - self.inventory.filters = value + result = attempt_and_reissue( + self, + __class__.Format.InventoryFilters, + self.inventory, + "filters", + value, + ) + self.inventory.filters = result + else: + self.inventory.filters = value # ========================================================================= @@ -152,14 +204,16 @@ def bar(self) -> uint16: @bar.setter def bar(self, value: uint16): if self.validate_assignment: - attempt_and_reissue(self, "filters", value) - - self.inventory.filters = value + result = attempt_and_reissue( + self, __class__.Format.InventoryFilters, self.inventory, "bar", value + ) + self.inventory.bar = result + else: + self.inventory.bar = value # ========================================================================= - @validate_call - def set_inventory_filter(self, index: int, item: str): + def set_inventory_filter(self, index: int64, item: Optional[ItemName]): """ Sets the item filter at a particular index. If ``item`` is set to ``None``, the item filter at that location is removed. @@ -173,39 +227,43 @@ def set_inventory_filter(self, index: int, item: str): :exception IndexError: If ``index`` lies outside the range ``[0, inventory_size)``. """ - - if self.inventory.filters is None: - self.inventory.filters = Filters() - - # TODO: maybe make an 'ItemID' item with a custom validator? if item is not None: - if item not in items.raw: - # TODO: this should be a warning, in case its just that the item - # is unknown by Draftsman - raise InvalidItemError(item) - - if not 0 <= index < self.inventory_size: - raise IndexError( - "Filter index ({}) not in range [0, {})".format( - index, self.inventory_size - ) - ) + try: + new_entry = FilterEntry(index=index, name=item) + new_entry.index += 1 + except ValidationError as e: + raise DataFormatError(e) from None + + new_filters = ( + self.inventory.filters if self.inventory.filters is not None else [] + ) # Check to see if filters already contains an entry with the same index - for i, filter in enumerate(self.inventory.filters.root): # TODO: change this + found_index = None + for i, filter in enumerate(new_filters): if filter["index"] == index + 1: # Index already exists in the list if item is None: # Delete the entry - del self.inventory.filters.root[i] + del new_filters[i] else: # Set the new value # self.inventory["filters"][i] = {"index": index+1,"name": item} - self.inventory.filters.root[i].name = item - return - - # If no entry with the same index was found - self.inventory.filters.root.append({"index": index + 1, "name": item}) + new_filters[i]["name"] = item + found_index = i + break + + if found_index is None: + # If no entry with the same index was found + new_filters.append(new_entry) + + result = attempt_and_reissue( + self, + __class__.Format.InventoryFilters, + self.inventory, + "filters", + new_filters, + ) + self.inventory.filters = result - def set_inventory_filters(self, filters): - # type: (list) -> None + def set_inventory_filters(self, filters: list): """ Sets all the inventory filters of the Entity. @@ -230,31 +288,14 @@ def set_inventory_filters(self, filters): :exception IndexError: If the index of one of the entries lies outside the range ``[0, inventory_size)``. """ - if filters is None: - self.inventory.pop("filters", None) - return - - try: - filters = Filters(root=filters).model_dump( - by_alias=True, - exclude_none=True, - exclude_defaults=True, - ) - except ValidationError as e: - six.raise_from(DataFormatError(e), None) - - # Make sure the items are item signals - for item in filters: - if item["name"] not in items.raw: - raise InvalidItemError(item) - - for i in range(len(filters)): - self.set_inventory_filter(filters[i]["index"] - 1, filters[i]["name"]) + result = attempt_and_reissue( + self, __class__.Format.InventoryFilters, self.inventory, "filters", filters + ) + self.inventory.filters = result # ========================================================================= - def merge(self, other): - # type: (Entity) -> None + def merge(self, other: "InventoryFilterMixin"): super().merge(other) # self.inventory = {} diff --git a/draftsman/classes/mixins/io_type.py b/draftsman/classes/mixins/io_type.py index 8307938..1bd8d7f 100644 --- a/draftsman/classes/mixins/io_type.py +++ b/draftsman/classes/mixins/io_type.py @@ -5,7 +5,7 @@ from draftsman.classes.exportable import attempt_and_reissue from pydantic import BaseModel, Field -from typing import Literal, Optional +from typing import Any, Literal, Optional from typing import TYPE_CHECKING @@ -31,14 +31,14 @@ class Format(BaseModel): def __init__(self, name: str, similar_entities: list[str], **kwargs): self._root: __class__.Format - super(IOTypeMixin, self).__init__(name, similar_entities, **kwargs) + super().__init__(name, similar_entities, **kwargs) self.io_type = "input" # Default # Import dict (internal) format if "type" in kwargs: self.io_type = kwargs["type"] # More user-friendly format in line with attribute name - elif "io_type" in kwargs: + else: # "io_type" in kwargs: self.io_type = kwargs["io_type"] # ========================================================================= @@ -73,7 +73,7 @@ def io_type(self, value: Literal["input", "output", None]): # ========================================================================= - def merge(self, other: "Entity"): + def merge(self, other: "IOTypeMixin"): super().merge(other) self.io_type = other.io_type diff --git a/draftsman/classes/mixins/mode_of_operation.py b/draftsman/classes/mixins/mode_of_operation.py index 0523c41..4462ad3 100644 --- a/draftsman/classes/mixins/mode_of_operation.py +++ b/draftsman/classes/mixins/mode_of_operation.py @@ -27,7 +27,7 @@ class Format(BaseModel): pass @property - def mode_of_operation(self) -> InserterModeOfOperation: + def mode_of_operation(self) -> Optional[InserterModeOfOperation]: """ The behavior that the inserter should follow when connected to a circuit network. @@ -42,11 +42,18 @@ def mode_of_operation(self) -> InserterModeOfOperation: return self.control_behavior.circuit_mode_of_operation @mode_of_operation.setter - def mode_of_operation(self, value: InserterModeOfOperation): + def mode_of_operation(self, value: Optional[InserterModeOfOperation]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_mode_of_operation", value) - - self.control_behavior.circuit_mode_of_operation = value + result = attempt_and_reissue( + self, + type(self).Format.ControlBehavior, + self.control_behavior, + "circuit_mode_of_operation", + value, + ) + self.control_behavior.circuit_mode_of_operation = result + else: + self.control_behavior.circuit_mode_of_operation = value class LogisticModeOfOperationMixin: # (ControlBehaviorMixin) @@ -69,7 +76,7 @@ class Format(BaseModel): pass @property - def mode_of_operation(self) -> LogisticModeOfOperation: + def mode_of_operation(self) -> Optional[LogisticModeOfOperation]: """ The behavior that the logistic container should follow when connected to a circuit network. @@ -84,8 +91,15 @@ def mode_of_operation(self) -> LogisticModeOfOperation: return self.control_behavior.circuit_mode_of_operation @mode_of_operation.setter - def mode_of_operation(self, value: LogisticModeOfOperation): + def mode_of_operation(self, value: Optional[LogisticModeOfOperation]): if self.validate_assignment: - attempt_and_reissue(self, "circuit_mode_of_operation", value) - - self.control_behavior.circuit_mode_of_operation = value + result = attempt_and_reissue( + self, + type(self).Format.ControlBehavior, + self.control_behavior, + "circuit_mode_of_operation", + value, + ) + self.control_behavior.circuit_mode_of_operation = result + else: + self.control_behavior.circuit_mode_of_operation = value diff --git a/draftsman/classes/mixins/modules.py b/draftsman/classes/mixins/modules.py index a320e5a..400e11b 100644 --- a/draftsman/classes/mixins/modules.py +++ b/draftsman/classes/mixins/modules.py @@ -25,7 +25,7 @@ def ensure_not_too_many_modules( ): if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "ModulesMixin" = info.context["object"] @@ -37,16 +37,16 @@ def ensure_not_too_many_modules( return value if entity.module_slots_occupied > entity.total_module_slots: - issue = ModuleCapacityWarning( - "Current number of module slots used ({}) greater than max module capacity ({}) for entity '{}'".format( - entity.module_slots_occupied, - entity.total_module_slots, - entity.name, + warning_list.append( + ModuleCapacityWarning( + "Current number of module slots used ({}) greater than max module capacity ({}) for entity '{}'".format( + entity.module_slots_occupied, + entity.total_module_slots, + entity.name, + ) ) ) - warning_list.append(issue) - return value @field_validator("items", check_fields=False) @@ -56,7 +56,7 @@ def ensure_module_type_matches_entity( ): if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "ModulesMixin" = info.context["object"] @@ -77,14 +77,14 @@ def ensure_module_type_matches_entity( else: reason_string = "this machine does not accept modules" - issue = ModuleNotAllowedWarning( - "Cannot add module '{}' to '{}'; {}".format( - item, entity.name, reason_string + warning_list.append( + ModuleNotAllowedWarning( + "Cannot add module '{}' to '{}'; {}".format( + item, entity.name, reason_string + ) ) ) - warning_list.append(issue) - return value def __init__(self, name: str, similar_entities: list[str], **kwargs): diff --git a/draftsman/classes/mixins/read_rail_signal.py b/draftsman/classes/mixins/read_rail_signal.py index 402e218..daec358 100644 --- a/draftsman/classes/mixins/read_rail_signal.py +++ b/draftsman/classes/mixins/read_rail_signal.py @@ -62,7 +62,7 @@ def red_output_signal(self) -> Optional[SignalID]: return self.control_behavior.red_output_signal @red_output_signal.setter - def red_output_signal(self, value: Union[str, SignalID, None]): # TODO: SignalStr + def red_output_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -103,9 +103,7 @@ def yellow_output_signal(self) -> Optional[SignalID]: return self.control_behavior.yellow_output_signal @yellow_output_signal.setter - def yellow_output_signal( - self, value: Union[str, SignalID, None] - ): # TODO: SignalStr + def yellow_output_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -145,7 +143,7 @@ def green_output_signal(self) -> Optional[SignalID]: return self.control_behavior.green_output_signal @green_output_signal.setter - def green_output_signal(self, value: Union[str, SignalID, None]): # TODO: SignalStr + def green_output_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, diff --git a/draftsman/classes/mixins/recipe.py b/draftsman/classes/mixins/recipe.py index 7d1e251..a070734 100644 --- a/draftsman/classes/mixins/recipe.py +++ b/draftsman/classes/mixins/recipe.py @@ -34,20 +34,20 @@ class Format(BaseModel): def ensure_recipe_known(cls, value: Optional[str], info: ValidationInfo): if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value warning_list: list = info.context["warning_list"] if value not in recipes.raw: - issue = UnknownRecipeWarning( - "'{}' is not a known recipe;{}".format( - value, get_suggestion(value, recipes.raw.keys(), 1) + warning_list.append( + UnknownRecipeWarning( + "'{}' is not a known recipe;{}".format( + value, get_suggestion(value, recipes.raw.keys(), 1) + ) ) ) - warning_list.append(issue) - return value @field_validator("recipe") @@ -57,7 +57,7 @@ def ensure_recipe_allowed_in_machine( ): if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "RecipeMixin" = info.context["object"] @@ -67,14 +67,14 @@ def ensure_recipe_allowed_in_machine( return value if value in recipes.raw and value not in entity.allowed_recipes: - issue = RecipeLimitationWarning( - "'{}' is not a valid recipe for '{}'; allowed recipes are: {}".format( - value, entity.name, entity.allowed_recipes + warning_list.append( + RecipeLimitationWarning( + "'{}' is not a valid recipe for '{}'; allowed recipes are: {}".format( + value, entity.name, entity.allowed_recipes + ) ) ) - warning_list.append(issue) - return value @field_validator("recipe", mode="after") @@ -82,17 +82,17 @@ def ensure_recipe_allowed_in_machine( def check_items_fit_in_recipe(cls, value: Optional[str], info: ValidationInfo): if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value - print("run") - entity: "RecipeMixin" = info.context["object"] - if entity.recipe is None or entity.items is None: + if entity.items == {}: return value warning_list: list = info.context["warning_list"] + # TODO: display all items that don't fit with the current recipe in + # one warnings for item in entity.items: if item not in entity.allowed_items: warning_list.append( diff --git a/draftsman/classes/mixins/request_filters.py b/draftsman/classes/mixins/request_filters.py index 1dd5dc4..326bf0f 100644 --- a/draftsman/classes/mixins/request_filters.py +++ b/draftsman/classes/mixins/request_filters.py @@ -1,13 +1,12 @@ # request_filters.py from draftsman.classes.exportable import attempt_and_reissue -from draftsman.signatures import RequestFilters from draftsman.data import items -from draftsman.error import InvalidItemError, DataFormatError +from draftsman.error import DataFormatError +from draftsman.signatures import RequestFilter -from pydantic import BaseModel, Field, ValidationError, validate_call -import six -from typing import Optional +from pydantic import BaseModel, Field, ValidationError, field_validator +from typing import Any, Optional, Sequence from typing import TYPE_CHECKING @@ -22,14 +21,30 @@ class RequestFiltersMixin: """ class Format(BaseModel): - request_filters: Optional[RequestFilters] = Field( - RequestFilters([]), + request_filters: Optional[list[RequestFilter]] = Field( + [], description=""" Key which holds all of the logistics requests that this entity has. """, ) + @field_validator("request_filters", mode="before") + @classmethod + def normalize_validate(cls, value: Any): + if isinstance(value, Sequence): + result = [] + for i, entry in enumerate(value): + if isinstance(entry, (list, tuple)): + result.append( + {"index": i + 1, "name": entry[0], "count": entry[1]} + ) + else: + result.append(entry) + return result + else: + return value + def __init__(self, name: str, similar_entities: list[str], **kwargs): self._root: __class__.Format @@ -40,14 +55,14 @@ def __init__(self, name: str, similar_entities: list[str], **kwargs): # ========================================================================= @property - def request_filters(self) -> RequestFilters: + def request_filters(self) -> Optional[list[RequestFilter]]: """ TODO """ return self._root.request_filters @request_filters.setter - def request_filters(self, value: RequestFilters): + def request_filters(self, value: Optional[list[RequestFilter]]): if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "request_filters", value @@ -67,49 +82,44 @@ def set_request_filter(self, index: int, item: str, count: Optional[int] = None) :param index: The index of the item request. :param item: The item name to request, or ``None``. :param count: The amount to request. If set to ``None``, it defaults to - the stack size of ``item``. + ``1``. :exception TypeError: If ``index`` is not an ``int``, ``item`` is not a ``str`` or ``None``, or ``count`` is not an ``int``. :exception InvalidItemError: If ``item`` is not a valid item name. :exception IndexError: If ``index`` is not in the range ``[0, 1000)``. """ - # try: # TODO - # index = signatures.INTEGER.validate(index) - # item = signatures.STRING_OR_NONE.validate(item) - # count = signatures.INTEGER_OR_NONE.validate(count) - # except SchemaError as e: - # six.raise_from(TypeError(e), None) - - # if item is not None and item not in items.raw: - # raise InvalidItemError("'{}'".format(item)) - # if not 0 <= index < 1000: - # raise IndexError("Filter index ({}) not in range [0, 1000)".format(index)) - if count is None: # get item's stack size - count = items.raw.get(item, {}).get("stack_size", 0) - if count < 0: - raise ValueError("Filter count ({}) must be positive".format(count)) - - if self.request_filters is None: - self.request_filters = [] + if item is not None: + try: + new_entry = RequestFilter(index=index, name=item, count=count) + new_entry.index += 1 + except ValidationError as e: + raise DataFormatError(e) from None + + new_filters = self.request_filters if self.request_filters is not None else [] # Check to see if filters already contains an entry with the same index - for i, filter in enumerate(self.request_filters): + found_index = None + for i, filter in enumerate(new_filters): if filter["index"] == index + 1: # Index already exists in the list if item is None: # Delete the entry - del self.request_filters[i] + del new_filters[i] else: # Set the new name + value - self.request_filters[i]["name"] = item - self.request_filters[i]["count"] = count - return + new_filters[i]["name"] = item + new_filters[i]["count"] = count + found_index = i + break # If no entry with the same index was found - self.request_filters.append({"index": index + 1, "name": item, "count": count}) + if found_index is None: + new_filters.append(new_entry) - @validate_call - def set_request_filters( - self, filters: list[tuple[str, int]] - ): # TODO: int dimension + result = attempt_and_reissue( + self, type(self).Format, self._root, "request_filters", new_filters + ) + self.request_filters = result + + def set_request_filters(self, filters: list[tuple[str, int]]): """ Sets all the request filters of the Entity in a shorthand format, where filters is of the format:: @@ -124,18 +134,10 @@ def set_request_filters( specified above. :exception InvalidItemError: If ``item_x`` is not a valid item name. """ - # Validate filters - try: - filters = RequestFilters(root=filters) - except ValidationError as e: - six.raise_from(DataFormatError(e), None) - - # Make sure the items are items - # for item in filters: - # if item["name"] not in items.raw: - # raise InvalidItemError(item["name"]) - - self._root.request_filters = filters + result = attempt_and_reissue( + self, type(self).Format, self._root, "request_filters", filters + ) + self._root.request_filters = result def merge(self, other: "Entity"): super().merge(other) diff --git a/draftsman/classes/mixins/request_items.py b/draftsman/classes/mixins/request_items.py index bfab928..1318aed 100644 --- a/draftsman/classes/mixins/request_items.py +++ b/draftsman/classes/mixins/request_items.py @@ -4,7 +4,8 @@ from draftsman.data import items from draftsman.classes.exportable import attempt_and_reissue from draftsman.constants import ValidationMode -from draftsman.signatures import DraftsmanBaseModel, SignalID, uint32, get_suggestion +from draftsman.error import DataFormatError +from draftsman.signatures import DraftsmanBaseModel, SignalID, uint32, ItemName from draftsman.utils import reissue_warnings from draftsman.warning import ItemLimitationWarning, UnknownItemWarning @@ -25,7 +26,7 @@ class RequestItemsMixin: """ class Format(DraftsmanBaseModel): - items: Optional[dict[str, uint32]] = Field( + items: Optional[dict[ItemName, uint32]] = Field( {}, description=""" List of construction item requests (such as delivering modules for @@ -34,34 +35,6 @@ class Format(DraftsmanBaseModel): """, ) - @field_validator("items") - @classmethod - def validate_items_exist( - cls, value: Optional[dict[str, int]], info: ValidationInfo - ): - """ - Issue warnings if Draftsman cannot recognize the set item. - """ - if not info.context or value is None: - return value - if info.context["mode"] is ValidationMode.MINIMUM: - return value - - warning_list: list = info.context["warning_list"] - for k, _ in value.items(): - if k not in items.raw: - issue = UnknownItemWarning( - "Unknown item '{}'{}".format( - k, get_suggestion(k, items.raw.keys()) - ) - ) - if info.context["mode"] == "pedantic": - raise issue - else: - warning_list.append(issue) - - return value - @field_validator("items") @classmethod def ensure_in_allowed_items( @@ -73,27 +46,25 @@ def ensure_in_allowed_items( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "RequestItemsMixin" = info.context["object"] warning_list: list = info.context["warning_list"] - print(entity.allowed_items) - if entity.allowed_items is None: # entity not recognized return value for item in entity.items: if item not in entity.allowed_items: - issue = ItemLimitationWarning( - "Cannot request item '{}' to '{}'; this entity cannot recieve it".format( - item, entity.name + warning_list.append( + ItemLimitationWarning( + "Cannot request item '{}' to '{}'; this entity cannot recieve it".format( + item, entity.name + ) ) ) - warning_list.append(issue) - return value def __init__(self, name: str, similar_entities: list[str], **kwargs): @@ -121,12 +92,21 @@ def items(self) -> Optional[dict[str, uint32]]: @items.setter def items(self, value: dict[str, uint32]): if self.validate_assignment: - # Set the value beforehand, so the new value is available to the - # validation step - self._root.items = value # TODO: FIXME, this is bad - value = attempt_and_reissue( - self, type(self).Format, self._root, "items", value - ) + # In the validator functions for `items`, we use a lot of internal + # properties that operate on the current items value instead of the + # items value that we're setting + # Thus, in order for those to work, we set the items to the new + # value first, check for errors, and revert it back to the original + # value if it fails for whatever reason + try: + original_items = self._root.items + self._root.items = value + value = attempt_and_reissue( + self, type(self).Format, self._root, "items", value + ) + except DataFormatError as e: + self._root.items = original_items + raise e if value is None: self._root.items = {} @@ -136,7 +116,7 @@ def items(self, value: dict[str, uint32]): # ========================================================================= @reissue_warnings - def set_item_request(self, item: str, count: Optional[uint32]): # TODO: ItemID + def set_item_request(self, item: str, count: Optional[uint32]): """ Requests an amount of an item. Removes the item request if ``count`` is set to ``0`` or ``None``. Manages ``module_slots_occupied``. @@ -157,19 +137,25 @@ def set_item_request(self, item: str, count: Optional[uint32]): # TODO: ItemID if count is None: count = 0 - if not isinstance(item, str): # TODO: better - raise TypeError("Expected 'item' to be a str, found '{}'".format(item)) - if not isinstance(count, int): # TODO: better - raise TypeError("Expected 'count' to be an int, found '{}'".format(count)) + # TODO: inefficient + new_items = deepcopy(self.items) if count == 0: - self.items.pop(item, None) + new_items.pop(item, None) else: - self.items[item] = count + new_items[item] = count - self._root.items = attempt_and_reissue( - self, type(self).Format, self._root, "items", self.items - ) + try: + original_items = self._root.items + self._root.items = new_items + result = attempt_and_reissue( + self, type(self).Format, self._root, "items", new_items + ) + except DataFormatError as e: + self._root.items = original_items + raise e + else: + self._root.items = result # ========================================================================= diff --git a/draftsman/classes/mixins/stack_size.py b/draftsman/classes/mixins/stack_size.py index acc1ad8..b933995 100644 --- a/draftsman/classes/mixins/stack_size.py +++ b/draftsman/classes/mixins/stack_size.py @@ -58,7 +58,7 @@ def __init__(self, name: str, similar_entities: list[str], **kwargs): # ========================================================================= @property - def override_stack_size(self) -> int: + def override_stack_size(self) -> Optional[uint8]: """ The inserter's stack size override. Will use this unless a circuit stack size is set and enabled. @@ -72,7 +72,7 @@ def override_stack_size(self) -> int: return self._root.override_stack_size @override_stack_size.setter - def override_stack_size(self, value: int): # TODO: dimension + def override_stack_size(self, value: Optional[uint8]): if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "override_stack_size", value @@ -103,11 +103,11 @@ def circuit_stack_size_enabled(self) -> Optional[bool]: def circuit_stack_size_enabled(self, value: Optional[bool]): if self.validate_assignment: result = attempt_and_reissue( - self, + self, self.Format.ControlBehavior, self.control_behavior, - "circuit_set_stack_size", - value + "circuit_set_stack_size", + value, ) self.control_behavior.circuit_set_stack_size = result else: @@ -148,9 +148,9 @@ def stack_size_control_signal(self, value: Optional[SignalID]): result = attempt_and_reissue( self, self.Format.ControlBehavior, - self.control_behavior, - "stack_control_input_signal", - value + self.control_behavior, + "stack_control_input_signal", + value, ) self.control_behavior.stack_control_input_signal = result else: diff --git a/draftsman/classes/rail_planner.py b/draftsman/classes/rail_planner.py index 0b959c0..12390c1 100644 --- a/draftsman/classes/rail_planner.py +++ b/draftsman/classes/rail_planner.py @@ -1,21 +1,18 @@ # railplanner.py -# -*- encoding: utf-8 -*- -from __future__ import unicode_literals - -from draftsman.classes.association import Association from draftsman.classes.group import Group from draftsman.classes.entity_like import EntityLike -from draftsman.classes.vector import Vector +from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import Direction from draftsman.error import DraftsmanError from draftsman.data import items from draftsman.prototypes.straight_rail import StraightRail from draftsman.prototypes.curved_rail import CurvedRail -import math +from typing import Optional, Union +from typing import cast as typing_cast +import weakref from weakref import ReferenceType as Ref -from typing import Union class RailPlanner(Group): @@ -26,12 +23,14 @@ class RailPlanner(Group): def __init__( self, - name="rail", - head_position=[0, 0], - head_direction=Direction.NORTH, + name: str="rail", + head_position: Union[Vector, PrimitiveVector]=(0, 0), + head_direction: Direction=Direction.NORTH, **kwargs ): - # type: (str, Union[list, dict, tuple], int, **dict) -> None + """ + TODO + """ super(RailPlanner, self).__init__(**kwargs) if name in items.raw and items.raw[name]["type"] == "rail-planner": self.name = name @@ -42,17 +41,16 @@ def __init__( self._head_position = Vector(0, 0) - self.head_position = head_position + self.head_position = head_position # type: ignore self.head_direction = head_direction - self._last_rail_added: Association | None = None + self._last_rail_added: Optional[Ref] = None self.diagonal_side = 0 # left # ========================================================================= @property - def head_position(self): - # type: () -> Vector + def head_position(self) -> Vector: """ The turtle "head" of the rail planner. This is the rail-tile position where the next placed rail will go. @@ -68,18 +66,15 @@ def head_position(self): return self._head_position @head_position.setter - def head_position(self, value): - # type: (Union[dict, list, tuple]) -> None + def head_position(self, value: Union[Vector, PrimitiveVector]) -> None: # TODO: issue rail alignment warning if not on rail grid - self._head_position.update_from_other(value, int) self.diagonal_side = 0 # ========================================================================= @property - def head_direction(self): - # type: () -> Direction + def head_direction(self) -> Direction: """ The :py:enum:`.Direction` that the user intends to build the next rails in. Note that this direction is not necessarily equal to the direction @@ -96,15 +91,14 @@ def head_direction(self): return self._head_direction @head_direction.setter - def head_direction(self, value): - # type: (Direction) -> None + def head_direction(self, value: Direction) -> None: self._head_direction = Direction(value) self._direction = self._head_direction.next(eight_way=False) # ========================================================================= @property - def last_rail_added(self) -> Ref[StraightRail | CurvedRail] | None: + def last_rail_added(self) -> Optional[Ref[StraightRail | CurvedRail]]: """ Reference to the last rail entity that was added in the RailPlanner, or ``None`` if no entities have been added yet. Used internally, but @@ -117,8 +111,7 @@ def last_rail_added(self) -> Ref[StraightRail | CurvedRail] | None: # ========================================================================= - def move_forward(self, amount=1): - # type: (int) -> None + def move_forward(self, amount: int=1) -> None: """ Moves the :py:class:`.RailPlanner`'s head ``amount`` rail-tiles in the direction :py:attr:`.head_direction`. @@ -142,32 +135,14 @@ def move_forward(self, amount=1): :param amount: The amount of rails to place going in that direction. """ - cardinal_matrix = { - Direction.NORTH: {"x": 0, "y": -2}, - Direction.EAST: {"x": 2, "y": 0}, - Direction.SOUTH: {"x": 0, "y": 2}, - Direction.WEST: {"x": -2, "y": 0}, - } - diagonal_matrix = { - Direction.NORTHEAST: { - Direction.NORTHWEST: {"x": 0, "y": -2, "d": Direction.SOUTHEAST}, - Direction.SOUTHEAST: {"x": 2, "y": 0, "d": Direction.NORTHWEST}, - }, - Direction.SOUTHEAST: { - Direction.NORTHEAST: {"x": 2, "y": 0, "d": Direction.SOUTHWEST}, - Direction.SOUTHWEST: {"x": 0, "y": 2, "d": Direction.NORTHEAST}, - }, - Direction.SOUTHWEST: { - Direction.SOUTHEAST: {"x": 0, "y": 2, "d": Direction.NORTHWEST}, - Direction.NORTHWEST: {"x": -2, "y": 0, "d": Direction.SOUTHEAST}, - }, - Direction.NORTHWEST: { - Direction.SOUTHWEST: {"x": -2, "y": 0, "d": Direction.NORTHEAST}, - Direction.NORTHEAST: {"x": 0, "y": -2, "d": Direction.SOUTHWEST}, - }, - } if self.head_direction in {0, 2, 4, 6}: # Straight rails, easy - offset = cardinal_matrix[self.head_direction] + cardinal_matrix = { + Direction.NORTH: (0, -2), + Direction.EAST: (2, 0), + Direction.SOUTH: (0, 2), + Direction.WEST: (-2, 0), + } + cardinal_offset = cardinal_matrix[self.head_direction] for _ in range(amount): self.entities.append( self.straight_rail, @@ -175,30 +150,47 @@ def move_forward(self, amount=1): direction=self.head_direction, merge=True, ) - self._last_rail_added = Association(self.entities[-1]) - self.head_position["x"] += offset["x"] - self.head_position["y"] += offset["y"] + self._last_rail_added = weakref.ref(self.entities[-1]) + self.head_position.x += cardinal_offset[0] + self.head_position.y += cardinal_offset[1] else: # Diagonal rails, hard + diagonal_matrix: dict[Direction, dict[Direction, tuple[int, int, Direction]]] = { + Direction.NORTHEAST: { + Direction.NORTHWEST: (0, -2, Direction.SOUTHEAST), + Direction.SOUTHEAST: (2, 0, Direction.NORTHWEST), + }, + Direction.SOUTHEAST: { + Direction.NORTHEAST: (2, 0, Direction.SOUTHWEST), + Direction.SOUTHWEST: (0, 2, Direction.NORTHEAST), + }, + Direction.SOUTHWEST: { + Direction.SOUTHEAST: (0, 2, Direction.NORTHWEST), + Direction.NORTHWEST: (-2, 0, Direction.SOUTHEAST), + }, + Direction.NORTHWEST: { + Direction.SOUTHWEST: (-2, 0, Direction.NORTHEAST), + Direction.NORTHEAST: (0, -2, Direction.SOUTHWEST), + }, + } if self.diagonal_side == 0: real_direction = self.head_direction.previous(eight_way=False) else: real_direction = self.head_direction.next(eight_way=False) for _ in range(amount): - offset = diagonal_matrix[self.head_direction][real_direction] + diagonal_offset = diagonal_matrix[self.head_direction][real_direction] self.entities.append( self.straight_rail, tile_position=self.head_position, direction=real_direction, merge=True, ) - self._last_rail_added = Association(self.entities[-1]) - self.head_position.x += offset["x"] - self.head_position.y += offset["y"] - real_direction = offset["d"] + self._last_rail_added = weakref.ref(self.entities[-1]) + self.head_position.x += diagonal_offset[0] + self.head_position.y += diagonal_offset[1] + real_direction = diagonal_offset[2] self.diagonal_side = int(not self.diagonal_side) - def turn_left(self, amount=1): - # type: (int) -> None + def turn_left(self, amount: int=1) -> None: """ Places ``amount`` curved rails turning left from :py:attr:`head_position`, and places the head at the point just after the rails. Each increment of @@ -210,47 +202,47 @@ def turn_left(self, amount=1): :param amount: The amount of rails to place going in that direction. """ - matrix = { - Direction.NORTH: { - "offset": Vector(0, -2), - "head_offset": Vector(-4, -6), - "direction": Direction.NORTH, - }, - Direction.NORTHEAST: { - "offset": Vector(2, -2), - "head_offset": Vector(2, -8), - "direction": Direction.SOUTHWEST, - }, - Direction.EAST: { - "offset": Vector(4, 0), - "head_offset": Vector(6, -4), - "direction": Direction.EAST, - }, - Direction.SOUTHEAST: { - "offset": Vector(4, 2), - "head_offset": Vector(8, 2), - "direction": Direction.NORTHWEST, - }, - Direction.SOUTH: { - "offset": Vector(2, 4), - "head_offset": Vector(4, 6), - "direction": Direction.SOUTH, - }, - Direction.SOUTHWEST: { - "offset": Vector(0, 4), - "head_offset": Vector(-2, 8), - "direction": Direction.NORTHEAST, - }, - Direction.WEST: { - "offset": Vector(-2, 2), - "head_offset": Vector(-6, 4), - "direction": Direction.WEST, - }, - Direction.NORTHWEST: { - "offset": Vector(-2, 0), - "head_offset": Vector(-8, -2), - "direction": Direction.SOUTHEAST, - }, + matrix: dict[Direction, tuple[Vector, Vector, Direction]] = { + Direction.NORTH: ( + Vector(0, -2), # "offset" + Vector(-4, -6), # "head_offset" + Direction.NORTH, # "direction" + ), + Direction.NORTHEAST: ( + Vector(2, -2), + Vector(2, -8), + Direction.SOUTHWEST, + ), + Direction.EAST: ( + Vector(4, 0), + Vector(6, -4), + Direction.EAST, + ), + Direction.SOUTHEAST: ( + Vector(4, 2), + Vector(8, 2), + Direction.NORTHWEST, + ), + Direction.SOUTH: ( + Vector(2, 4), + Vector(4, 6), + Direction.SOUTH, + ), + Direction.SOUTHWEST: ( + Vector(0, 4), + Vector(-2, 8), + Direction.NORTHEAST, + ), + Direction.WEST: ( + Vector(-2, 2), + Vector(-6, 4), + Direction.WEST, + ), + Direction.NORTHWEST: ( + Vector(-2, 0), + Vector(-8, -2), + Direction.SOUTHEAST, + ), } diagonals = { Direction.NORTHEAST, @@ -269,61 +261,60 @@ def turn_left(self, amount=1): self.head_direction = self.head_direction.previous(eight_way=True) self.entities.append( self.curved_rail, - position=self.head_position + entry["offset"], - direction=entry["direction"], + position=self.head_position + entry[0], + direction=entry[2], merge=True, ) - self._last_rail_added = Association(self.entities[-1]) - self.head_position += entry["head_offset"] + self._last_rail_added = weakref.ref(self.entities[-1]) + self.head_position += entry[1] if self.head_direction in diagonals: self.diagonal_side = 1 # right - def turn_right(self, amount=1): - # type: (int) -> None + def turn_right(self, amount: int=1) -> None: """ TODO """ - matrix = { - Direction.NORTH: { - "offset": Vector(2, -2), - "head_offset": Vector(4, -6), - "direction": Direction.NORTHEAST, - }, - Direction.NORTHEAST: { - "offset": Vector(4, 0), - "head_offset": Vector(8, -2), - "direction": Direction.WEST, - }, - Direction.EAST: { - "offset": Vector(4, 2), - "head_offset": Vector(6, 4), - "direction": Direction.SOUTHEAST, - }, - Direction.SOUTHEAST: { - "offset": Vector(2, 4), - "head_offset": Vector(2, 8), - "direction": Direction.NORTH, - }, - Direction.SOUTH: { - "offset": Vector(0, 4), - "head_offset": Vector(-4, 6), - "direction": Direction.SOUTHWEST, - }, - Direction.SOUTHWEST: { - "offset": Vector(-2, 2), - "head_offset": Vector(-8, 2), - "direction": Direction.EAST, - }, - Direction.WEST: { - "offset": Vector(-2, 0), - "head_offset": Vector(-6, -4), - "direction": Direction.NORTHWEST, - }, - Direction.NORTHWEST: { - "offset": Vector(0, -2), - "head_offset": Vector(-2, -8), - "direction": Direction.SOUTH, - }, + matrix: dict[Direction, tuple[Vector, Vector, Direction]] = { + Direction.NORTH: ( + Vector(2, -2), # "offset" + Vector(4, -6), # "head_offset" + Direction.NORTHEAST, # "direction" + ), + Direction.NORTHEAST: ( + Vector(4, 0), + Vector(8, -2), + Direction.WEST, + ), + Direction.EAST: ( + Vector(4, 2), + Vector(6, 4), + Direction.SOUTHEAST, + ), + Direction.SOUTHEAST: ( + Vector(2, 4), + Vector(2, 8), + Direction.NORTH, + ), + Direction.SOUTH: ( + Vector(0, 4), + Vector(-4, 6), + Direction.SOUTHWEST, + ), + Direction.SOUTHWEST: ( + Vector(-2, 2), + Vector(-8, 2), + Direction.EAST, + ), + Direction.WEST: ( + Vector(-2, 0), + Vector(-6, -4), + Direction.NORTHWEST, + ), + Direction.NORTHWEST: ( + Vector(0, -2), + Vector(-2, -8), + Direction.SOUTH, + ), } diagonals = { Direction.NORTHEAST, @@ -342,17 +333,16 @@ def turn_right(self, amount=1): self.head_direction = self.head_direction.next(eight_way=True) self.entities.append( self.curved_rail, - position=self.head_position + entry["offset"], - direction=entry["direction"], + position=self.head_position + entry[0], + direction=entry[2], merge=True, ) - self._last_rail_added = Association(self.entities[-1]) - self.head_position += entry["head_offset"] + self._last_rail_added = weakref.ref(self.entities[-1]) + self.head_position += entry[1] if self.head_direction in diagonals: self.diagonal_side = 0 # left - def add_signal(self, entity, right=True, front=True): - # type: (Union[str, EntityLike], bool, bool) -> None + def add_signal(self, entity: Union[str, EntityLike], right: bool=True, front: bool=True) -> None: """ Adds a rail signal to the last placed rail. Defaults to the front right side of the last placed rail entity, determined by the current @@ -385,6 +375,8 @@ def add_signal(self, entity, right=True, front=True): # before placing any rail if self.last_rail_added is None: return + + last_rail_added = typing_cast(Union[CurvedRail, StraightRail], self.last_rail_added) diagonals = { Direction.NORTHEAST, @@ -392,15 +384,14 @@ def add_signal(self, entity, right=True, front=True): Direction.SOUTHWEST, Direction.NORTHWEST, } - rail_pos = self.last_rail_added.position - rail_dir = self.last_rail_added.direction - if self.last_rail_added.name == self.straight_rail: - print(rail_dir) + rail_pos = last_rail_added.position + rail_dir = last_rail_added.direction + if last_rail_added.name == self.straight_rail: if rail_dir in diagonals: # Diagonal Straight Rail # `front` has no effect, since there's only two valid spots - offset = (1, 0) - matrix = { + # diagonal_offset = (1, 0) + diagonal_matrix = { Direction.NORTHEAST: { Direction.SOUTHEAST: [Vector(-1, -1), Vector(1, 1)], Direction.NORTHWEST: [Vector(-2, -2), Vector(0, 0)], @@ -419,22 +410,22 @@ def add_signal(self, entity, right=True, front=True): }, } - index = int(right) - offset = matrix[self.head_direction][rail_dir][index] + diagonal_index = int(right) + diagonal_offset = diagonal_matrix[self.head_direction][rail_dir][diagonal_index] if right: - signal_dir = self.head_direction.opposite() + diagonal_signal_dir = self.head_direction.opposite() else: - signal_dir = self.head_direction + diagonal_signal_dir = self.head_direction - print(rail_pos, offset) - print(rail_pos + offset, signal_dir) self.entities.append( - name=entity, tile_position=rail_pos + offset, direction=signal_dir + entity, + tile_position=rail_pos + diagonal_offset, + direction=diagonal_signal_dir ) else: # Horizontal/Vertical straight rail - matrix = { + straight_matrix = { Direction.NORTH: [ Vector(-2, 0), Vector(-2, -1), @@ -461,21 +452,23 @@ def add_signal(self, entity, right=True, front=True): ], } - index = (int(right) << 1) | int(front) - offset = matrix[rail_dir][index] + straight_index = (int(right) << 1) | int(front) + straight_offset = straight_matrix[rail_dir][straight_index] if right: - signal_dir = rail_dir.opposite() + straight_signal_dir = rail_dir.opposite() else: - signal_dir = rail_dir + straight_signal_dir = rail_dir self.entities.append( - name=entity, tile_position=rail_pos + offset, direction=signal_dir + entity, + tile_position=rail_pos + straight_offset, + direction=straight_signal_dir ) else: # Curved rail # fmt: off - matrix = { + curved_matrix = { (Direction.NORTH, Direction.SOUTH): [ {"offset": Vector(-1, -4), "direction": Direction.SOUTHEAST}, {"offset": Vector(2, 3), "direction": Direction.SOUTH}, @@ -575,17 +568,18 @@ def add_signal(self, entity, right=True, front=True): } # fmt: on - index = (int(right) << 1) | int(front) + curved_index = (int(right) << 1) | int(front) permutation = (rail_dir, self.head_direction) - offset = matrix[permutation][index]["offset"] - signal_dir = matrix[permutation][index]["direction"] + curved_offset = curved_matrix[permutation][curved_index]["offset"] + curved_signal_dir = curved_matrix[permutation][curved_index]["direction"] self.entities.append( - entity, tile_position=rail_pos + offset, direction=signal_dir + entity, + tile_position=rail_pos + curved_offset, + direction=curved_signal_dir ) - def add_station(self, entity, station=None, right=True): - # type: (Union[str, EntityLike], str, bool) -> None + def add_station(self, entity: Union[str, EntityLike], station: Optional[str]=None, right: bool=True) -> None: """ Adds a train station at the :py:attr:`head_position` on the specified side. @@ -606,21 +600,23 @@ def add_station(self, entity, station=None, right=True): # before placing any rail if self.last_rail_added is None: return + + last_rail_added = typing_cast(Union[CurvedRail, StraightRail], self.last_rail_added) - if self.last_rail_added.name == self.curved_rail: - raise DraftsmanError( + if last_rail_added.name == self.curved_rail: + raise DraftsmanError( # TODO: more descriptive error "Cannot place train stop on a curved rail" - ) # TODO: fixme + ) diagonals = { Direction.NORTHEAST, Direction.SOUTHEAST, Direction.SOUTHWEST, Direction.NORTHWEST, } - if self.last_rail_added.direction in diagonals: - raise DraftsmanError( + if last_rail_added.direction in diagonals: + raise DraftsmanError( # TODO: more descriptive error "Cannot place train stop on a diagonal rail" - ) # TODO: fixme + ) matrix = { Direction.NORTH: Vector(2, 0), @@ -629,15 +625,15 @@ def add_station(self, entity, station=None, right=True): Direction.WEST: Vector(0, -2), } if right: - rail_dir = self.last_rail_added.direction + rail_dir = last_rail_added.direction else: - rail_dir = self.last_rail_added.direction.opposite() + rail_dir = last_rail_added.direction.opposite() offset = matrix[rail_dir] self.entities.append( entity, - position=self.last_rail_added.position + offset, + position=last_rail_added.position + offset, direction=rail_dir, station=station, ) diff --git a/draftsman/classes/schedule.py b/draftsman/classes/schedule.py index 395f319..f9da1c5 100644 --- a/draftsman/classes/schedule.py +++ b/draftsman/classes/schedule.py @@ -14,11 +14,20 @@ ) from draftsman.error import DataFormatError from draftsman.prototypes.locomotive import Locomotive -from draftsman.signatures import Condition, DraftsmanBaseModel, Stop, uint32, uint64 +from draftsman.signatures import Condition, DraftsmanBaseModel, uint32 import copy -from pydantic import ConfigDict, Field, ValidationError -from typing import Literal, Optional, Union +from pydantic import ( + ConfigDict, + Field, + GetCoreSchemaHandler, + ValidationError, + field_serializer, + field_validator, + model_validator, +) +from pydantic_core import CoreSchema, core_schema +from typing import Any, Literal, Mapping, Optional, Union # TODO: make dataclass? @@ -49,24 +58,10 @@ class WaitCondition(Exportable): """ class Format(DraftsmanBaseModel): - # type: Literal[ - # "time", - # "inactivity", - # "full", - # "empty", - # "item_count", - # "fluid_count", - # "circuit", - # "passenger_present", - # "passenger_not_present", - # ] = Field( - # ..., - # description=""" - # Exactly what type of behavior this condition is. - # """, - # ) - type: WaitConditionType = Field(...) - compare_type: Literal["or", "and"] = Field( + type: WaitConditionType = Field( + ..., description="""The type of wait condition.""" + ) + compare_type: WaitConditionCompareType = Field( "or", description=""" The boolean operation to perform in relation to the next condition @@ -74,8 +69,8 @@ class Format(DraftsmanBaseModel): the 'compare_type' of a wait condition indicates it's relation to the *previous* wait condition, not the following. This means that if you want to 'and' two wait conditions together, you need to set the - 'compare_type' of the second condition to 'and'; the value of the - first is effectively ignored. + 'compare_type' of the second condition to 'and'; the 'compare_type' + of the first condition is effectively ignored. """, ) ticks: Optional[uint32] = Field( @@ -96,6 +91,14 @@ class Format(DraftsmanBaseModel): """, ) + @model_validator(mode="wrap") + @classmethod + def handle_class_instance(cls, value: Any, handler): + if isinstance(value, WaitCondition): + return value + else: + return handler(value) + model_config = ConfigDict(title="WaitCondition") def __init__( @@ -149,12 +152,18 @@ def __init__( self.ticks = 5 * Ticks.SECOND else: self.ticks = ticks - self.condition = condition + if type in { + WaitConditionType.ITEM_COUNT, + WaitConditionType.FLUID_COUNT, + WaitConditionType.CIRCUIT_CONDITION, + }: + self.condition = Condition() if condition is None else condition + else: + self.condition = condition self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # def to_dict(self) -> dict: # result = {"type": self.type, "compare_type": self.compare_type} @@ -263,8 +272,6 @@ def validate( } try: - # self.Format(**self._root, position=self.global_position, entity_number=0) - # self._root.position = self.global_position result = self.Format.model_validate( self._root, strict=False, context=context ) @@ -276,21 +283,7 @@ def validate( except ValidationError as e: output.error_list.append(DataFormatError(e)) - if mode is ValidationMode.MINIMUM: - return output - - if mode is ValidationMode.PEDANTIC: - warning_list = output.error_list - else: - warning_list = output.warning_list - - warning_list += context["warning_list"] - - if len(output.error_list) == 0: - # Set the `is_valid` attribute - # This means that if mode="pedantic", an entity that issues only - # warnings will still not be considered valid - super().validate() + output.warning_list += context["warning_list"] return output @@ -308,9 +301,7 @@ def __and__( other_copy._conditions[0].compare_type = "and" return WaitConditions([self_copy] + other_copy._conditions) else: - raise ValueError( - "Can only perform this operation on or objects" - ) + return NotImplemented def __rand__( self, other: Union["WaitCondition", "WaitConditions"] @@ -321,9 +312,7 @@ def __rand__( self_copy.compare_type = "and" return WaitConditions(other_copy._conditions + [self_copy]) else: - raise ValueError( - "Can only perform this operation on or objects" - ) + return NotImplemented def __or__( self, other: Union["WaitCondition", "WaitConditions"] @@ -337,9 +326,7 @@ def __or__( other_copy._conditions[0].compare_type = "or" return WaitConditions([self_copy] + other_copy._conditions) else: - raise ValueError( - "Can only perform this operation on or objects" - ) + return NotImplemented def __ror__( self, other: Union["WaitCondition", "WaitConditions"] @@ -350,9 +337,7 @@ def __ror__( self_copy.compare_type = "or" return WaitConditions(other_copy._conditions + [self_copy]) else: - raise ValueError( - "Can only perform this operation on or objects" - ) + return NotImplemented def __eq__(self, other: "WaitCondition") -> bool: return ( @@ -364,16 +349,6 @@ def __eq__(self, other: "WaitCondition") -> bool: ) def __repr__(self) -> str: - if self.type in {WaitConditionType.TIME_PASSED, WaitConditionType.INACTIVITY}: - optional = ", ticks={}".format(self.ticks) - elif self.type in { - WaitConditionType.ITEM_COUNT, - WaitConditionType.FLUID_COUNT, - WaitConditionType.CIRCUIT_CONDITION, - }: - optional = ", condition={}".format(repr(self.condition)) - else: - optional = "" return "{{{}}}".format(str(self._root)) @@ -416,6 +391,14 @@ def __getitem__(self, index) -> WaitCondition: def __repr__(self) -> str: return "{}".format(repr(self._conditions)) + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_after_validator_function( + cls, handler(list[WaitCondition.Format]) + ) + class Schedule(Exportable): """ @@ -425,44 +408,114 @@ class Schedule(Exportable): """ class Format(DraftsmanBaseModel): - locomotives: list[uint64] = [] - stops: list[Stop] = [] + class Stop(DraftsmanBaseModel): + station: str = Field( + ..., description="""The name of the station for this particular stop.""" + ) + wait_conditions: WaitConditions = Field( + [], + description=""" + A list of wait conditions that a train with this schedule must satisfy + in order proceed from the associated 'station' name.""", + ) - def __init__(self, locomotives=[], schedule=[]): + @field_validator("wait_conditions", mode="before") + @classmethod + def instantiate_wait_conditions_list(cls, value: Any): + if isinstance(value, list): + return WaitConditions(value) + else: + return value + + # @field_validator("wait_conditions", mode="after") + # @classmethod + # def test(cls, value: Any): + # print("test") + # print(value) + # print(type(value)) + # return value + + @field_serializer("wait_conditions") + def serialize_wait_conditions(self, value: WaitConditions, _): + print("serialize!") + return value.to_dict() + + # _locomotives: list[Association.Format] = PrivateAttr() + + locomotives: list[Association.Format] = Field( + [], + description=""" + A list of the 'entity_number' of each locomotive in a blueprint that + has this schedule. + """, + ) + schedule: list[Stop] = Field( + [], + description=""" + The list of all train stops and their conditions associated with + this schedule. + """, + ) + + def __init__( + self, + locomotives: list[Association] = [], + schedule: list[Format.Stop] = [], + validate: Union[ + ValidationMode, Literal["none", "minimum", "strict", "pedantic"] + ] = ValidationMode.STRICT, + validate_assignment: Union[ + ValidationMode, Literal["none", "minimum", "strict", "pedantic"] + ] = ValidationMode.STRICT, + ): """ TODO """ - self._locomotives: list[Association] = [] - for locomotive in locomotives: - self._locomotives.append(locomotive) - self._stops: list[dict] = [] - for stop in schedule: - if not isinstance(stop["wait_conditions"], WaitConditions): - self._stops.append( - { - "station": stop["station"], - "wait_conditions": WaitConditions(stop["wait_conditions"]), - } - ) - else: - self._stops.append(stop) + self._root: __class__.Format + + super().__init__() + + # Construct root + self._root = __class__.Format.model_validate( + {"locomotives": locomotives, "schedule": schedule}, + context={"construction": True, "mode": ValidationMode.NONE}, + ) + # self._root._locomotives = locomotives + + # TODO: do I have to convert ints to associations here? + # self.locomotives: list[Association] = [] + # for locomotive in locomotives: + # self.locomotives.append(locomotive) + + # self._stops: list[dict] = [] + # for stop in self.stops: + # if not isinstance(stop["wait_conditions"], WaitConditions): + # # self.stops.append( + # # { + # # "station": stop["station"], + # # "wait_conditions": WaitConditions(stop["wait_conditions"]), + # # } + # # ) + # stop["wait_conditions"] = WaitConditions(stop["wait_conditions"]) + + self.validate_assignment = validate_assignment + + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @property - def locomotives(self): - # type: () -> list[Association] + def locomotives(self) -> list[Association]: """ The list of :py:class:`Association`s to each :py:class:`Locomotive` that uses this particular ``Schedule``. Read only; use :py:meth:`add_locomotive` or :py:meth:`remove_locomotive` to change this list. """ - return self._locomotives + return self._root.locomotives @property - def stops(self): - # type: () -> list[dict] + def stops(self) -> list[Format.Stop]: """ A list of dictionaries of the format: @@ -481,12 +534,11 @@ def stops(self): :returns: A ``list`` of ``dict``s in the format specified above. """ - return self._stops + return self._root.schedule # ========================================================================= - def add_locomotive(self, locomotive): - # type: (Locomotive) -> None + def add_locomotive(self, locomotive: Locomotive): """ Adds a locomotive to the set of locomotives associated with this schedule. @@ -501,11 +553,10 @@ def add_locomotive(self, locomotive): raise TypeError("'locomotive' must be an instance of ") loco_association = Association(locomotive) - if loco_association not in self._locomotives: - self._locomotives.append(loco_association) + if loco_association not in self.locomotives: + self.locomotives.append(loco_association) - def remove_locomotive(self, locomotive): - # type: (Locomotive) -> None + def remove_locomotive(self, locomotive: Locomotive): """ Removes a locomotive from the set of locomotives assicated with this schedule. @@ -516,10 +567,11 @@ def remove_locomotive(self, locomotive): :raises ValueError: If the specified locomotive doesn't currently exist in this schedule's locomotives. """ - self._locomotives.remove(Association(locomotive)) + self.locomotives.remove(Association(locomotive)) - def append_stop(self, name, wait_conditions=None): - # type: (str, list[dict]) -> None + def append_stop( + self, name: str, wait_conditions: Union[WaitCondition, WaitConditions] = None + ): """ Adds a stop to the end of the list of stations. @@ -529,8 +581,12 @@ def append_stop(self, name, wait_conditions=None): """ self.insert_stop(len(self.stops), name, wait_conditions) - def insert_stop(self, index, name, wait_conditions=None): - # type: (int, str, list[dict]) -> None + def insert_stop( + self, + index: int, + name: str, + wait_conditions: Union[WaitCondition, WaitConditions] = None, + ): """ Inserts a stop at ``index`` into the list of stations. @@ -544,14 +600,19 @@ def insert_stop(self, index, name, wait_conditions=None): elif isinstance(wait_conditions, WaitCondition): wait_conditions = WaitConditions([wait_conditions]) - self._stops.insert(index, {"station": name, "wait_conditions": wait_conditions}) + self.stops.insert( + index, self.Format.Stop(station=name, wait_conditions=wait_conditions) + ) - def remove_stop(self, name, wait_conditions=None): + def remove_stop( + self, name: str, wait_conditions: Union[WaitCondition, WaitConditions] = None + ): """ Removes a stop with a particular ``name`` and ``wait_conditions``. If - ``wait_conditions`` is not specified, the first stop with a matching - name is removed. Otherwise, both ``name`` and ``wait_conditions`` have - to strictly match in order for the stop to be removed. + ``wait_conditions`` is not specified, the first stop from the beginning + with a matching name is removed. Otherwise, both ``name`` and + ``wait_conditions`` have to strictly match in order for the stop to be + removed. :param name: The name of station to remove reference to. Case-sensitive. :param wait_conditions: Either a :py:class:`WaitCondition` or a @@ -564,18 +625,18 @@ def remove_stop(self, name, wait_conditions=None): wait_conditions = WaitConditions([wait_conditions]) if wait_conditions is None: - for i, stop in enumerate(self._stops): + for i, stop in enumerate(self.stops): if stop["station"] == name: - self._stops.pop(i) + self.stops.pop(i) return raise ValueError("No station with name '{}' found in schedule".format(name)) else: - for i, stop in enumerate(self._stops): + for i, stop in enumerate(self.stops): if ( stop["station"] == name and stop["wait_conditions"] == wait_conditions ): - self._stops.pop(i) + self.stops.pop(i) return raise ValueError( "No station with name '{}' and conditions '{}' found in schedule".format( @@ -584,30 +645,63 @@ def remove_stop(self, name, wait_conditions=None): ) def validate( - self, - ) -> ValidationResult: - return ValidationResult([], []) # TODO + self, mode: ValidationMode = ValidationMode.STRICT, force: bool = False + ) -> ValidationResult: # TODO: defer to parent + mode = ValidationMode(mode) - def to_dict(self) -> dict: # TODO: replace with parent - """ - Converts this Schedule into it's JSON dict form. + output = ValidationResult([], []) - .. NOTE: + if mode is ValidationMode.NONE or (self.is_valid and not force): + return output - Not directly JSON serializable; returns :py:class:`Association`s - which are usually converted in a parent method. + context = { + "mode": mode, + "object": self, + "warning_list": [], + "assignment": False, + } - :returns: A ``dict`` representation of this schedule. - """ - stop_list = [] - for stop in self._stops: - stop_list.append( - { - "station": stop["station"], - "wait_conditions": stop["wait_conditions"].to_dict(), - } + try: + result = self.Format.model_validate( + self._root, strict=False, context=context ) - return {"locomotives": list(self._locomotives), "schedule": stop_list} + # print("result:", result) + # Reassign private attributes + # TODO + # Acquire the newly converted data + self._root = result + except ValidationError as e: + output.error_list.append(DataFormatError(e)) + + output.warning_list += context["warning_list"] + + return output + + # def to_dict(self) -> dict: # TODO: defer to parent + # """ + # Converts this Schedule into it's JSON dict form. + + # .. NOTE: + + # Not directly JSON serializable; returns :py:class:`Association`s + # which are usually converted in a parent method. + + # :returns: A ``dict`` representation of this schedule. + # """ + # # stop_list = [] + # # for stop in self.stops: + # # stop_list.append( + # # { + # # "station": stop["station"], + # # "wait_conditions": stop["wait_conditions"] # .to_dict(), + # # } + # # ) + # # return {"locomotives": list(self.locomotives), "schedule": stop_list} + # return self._root.model_dump( + # by_alias=True, + # exclude_none=True, + # exclude=exclude_defaults=True + # ) # ========================================================================= diff --git a/draftsman/classes/schedule_list.py b/draftsman/classes/schedule_list.py index 5286185..e2b86e3 100644 --- a/draftsman/classes/schedule_list.py +++ b/draftsman/classes/schedule_list.py @@ -22,12 +22,15 @@ def __init__(self, initlist=None): if not isinstance(initlist, list): raise TypeError("'initlist' must be either a list or None") for elem in initlist: + print(elem) if isinstance(elem, Schedule): self.append(elem) elif isinstance(elem, dict): self.append(Schedule(**elem)) else: - raise DataFormatError("ScheduleList only accepts Schedule or dict entries") + raise DataFormatError( + "ScheduleList only accepts Schedule or dict entries" + ) def insert(self, index, schedule): """ @@ -38,12 +41,10 @@ def insert(self, index, schedule): self.data.insert(index, schedule) - def __getitem__(self, index): - # type: (int) -> Schedule + def __getitem__(self, index: int) -> Schedule: return self.data[index] - def __setitem__(self, index, item): - # type: (int, Schedule) -> None + def __setitem__(self, index: int, item: Schedule): if isinstance(item, MutableSequence): for i in range(len(item)): if not isinstance(item[i], Schedule): @@ -58,16 +59,13 @@ def __setitem__(self, index, item): self.data[index] = item - def __delitem__(self, index): - # type: (int) -> None + def __delitem__(self, index: int): del self.data[index] - def __len__(self): - # type: () -> int + def __len__(self) -> int: return len(self.data) - def __eq__(self, other): - # type: (ScheduleList) -> bool + def __eq__(self, other: "ScheduleList") -> bool: if not isinstance(other, ScheduleList): return False if len(self.data) != len(other.data): @@ -77,13 +75,16 @@ def __eq__(self, other): return False return True - def __repr__(self): - # type: () -> str + def __repr__(self) -> str: return "{}".format(repr(self.data)) # def __deepcopy__(self, memo): # pass # TODO, I think @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: - return core_schema.no_info_after_validator_function(cls, handler(list[dict])) # TODO: correct annotation + def __get_pydantic_core_schema__( + cls, _: Any, handler: GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_after_validator_function( + cls, handler(list[Schedule.Format]) + ) # pragma: no coverage diff --git a/draftsman/classes/spatial_data_structure.py b/draftsman/classes/spatial_data_structure.py index e8e87f7..27ab229 100644 --- a/draftsman/classes/spatial_data_structure.py +++ b/draftsman/classes/spatial_data_structure.py @@ -1,26 +1,21 @@ # spatial_data_structure.py -# -*- encoding: utf-8 -*- -import abc -import six - -from typing import Sequence, TYPE_CHECKING +from draftsman.classes.spatial_like import SpatialLike +from draftsman.classes.vector import PrimitiveVector +from draftsman.utils import AABB -if TYPE_CHECKING: # pragma: no coverage - from draftsman.classes.spatial_like import SpatialLike - from draftsman.utils import Point, AABB +import abc +from typing import Optional -@six.add_metaclass(abc.ABCMeta) -class SpatialDataStructure(object): +class SpatialDataStructure(metaclass=abc.ABCMeta): """ An abstract class used to implement some kind of spatial querying accelleration, such as a spatial hash-map or quadtree. """ @abc.abstractmethod - def add(self, item, merge=False): # pragma: no coverage - # type: (SpatialLike, bool) -> None + def add(self, item: SpatialLike, merge: bool=False) -> None: # pragma: no coverage """ Add a :py:class:`.SpatialLike` instance to the :py:class:`.SpatialHashMap`. @@ -29,8 +24,7 @@ def add(self, item, merge=False): # pragma: no coverage pass @abc.abstractmethod - def recursive_add(self, item, merge=False): # pragma: no coverage - # type: (SpatialLike, bool) -> None + def recursive_add(self, item: SpatialLike, merge: bool=False) -> None: # pragma: no coverage """ Add the leaf-most entities to the hashmap. @@ -43,8 +37,7 @@ def recursive_add(self, item, merge=False): # pragma: no coverage pass @abc.abstractmethod - def remove(self, item): # pragma: no coverage - # type: (SpatialLike) -> None + def remove(self, item: SpatialLike) -> None: # pragma: no coverage """ Remove the ``SpatialLike`` instance from the ``SpatialHashMap``. @@ -53,8 +46,7 @@ def remove(self, item): # pragma: no coverage pass @abc.abstractmethod - def recursive_remove(self, item): # pragma: no coverage - # type: (SpatialLike) -> None + def recursive_remove(self, item: SpatialLike) -> None: # pragma: no coverage """ Inverse of :py:meth:`recursive_add`. @@ -63,16 +55,24 @@ def recursive_remove(self, item): # pragma: no coverage pass @abc.abstractmethod - def clear(self): # pragma: no coverage - # type: () -> None + def clear(self) -> None: # pragma: no coverage """ Deletes all entries in the structure. """ pass @abc.abstractmethod - def get_all_entities(self): # pragma: no coverage - # type: () -> list[SpatialLike] + def handle_overlapping(self, item: SpatialLike, merge: bool) -> None: # pragma: no coverage + """ + Checks to see if the added object overlaps any other objects currently + contained within the map, and issues errors or warnings correspondingly. + + TODO: see if we can omit this somehow + """ + pass + + @abc.abstractmethod + def get_all_entities(self) -> list[SpatialLike]: # pragma: no coverage """ Get all the entities in the hash map. Iterates over every cell and returns the contents sequentially. Useful if you want to get all the @@ -83,8 +83,7 @@ def get_all_entities(self): # pragma: no coverage pass @abc.abstractmethod - def get_in_radius(self, radius, point, limit=None): # pragma: no coverage - # type: (float, Sequence[float], int) -> list[SpatialLike] + def get_in_radius(self, radius: float, point: PrimitiveVector, limit: Optional[int]=None) -> list[SpatialLike]: # pragma: no coverage """ Get all the entities whose ``collision_set`` overlaps a circle. @@ -99,8 +98,7 @@ def get_in_radius(self, radius, point, limit=None): # pragma: no coverage pass @abc.abstractmethod - def get_on_point(self, point, limit=None): # pragma: no coverage - # type: (Point, int) -> list[SpatialLike] + def get_on_point(self, point: PrimitiveVector, limit: Optional[int]=None) -> list[SpatialLike]: # pragma: no coverage """ Get all the entities whose ``collision_set`` overlaps a point. @@ -114,8 +112,7 @@ def get_on_point(self, point, limit=None): # pragma: no coverage pass @abc.abstractmethod - def get_in_area(self, area, limit=None): # pragma: no coverage - # type: (AABB, int) -> list[SpatialLike] + def get_in_area(self, area: AABB, limit: Optional[int]=None) -> list[SpatialLike]: # pragma: no coverage """ Get all the entities whose ``collision_box`` overlaps an area. diff --git a/draftsman/classes/spatial_hashmap.py b/draftsman/classes/spatial_hashmap.py index acc5448..fc8b93d 100644 --- a/draftsman/classes/spatial_hashmap.py +++ b/draftsman/classes/spatial_hashmap.py @@ -1,19 +1,19 @@ # spatial_hashmap.py -# -*- encoding: utf-8 -*- - -from __future__ import unicode_literals from draftsman.classes.collection import EntityCollection from draftsman.classes.spatial_like import SpatialLike from draftsman.classes.spatial_data_structure import SpatialDataStructure +from draftsman.classes.vector import PrimitiveVector, PrimitiveIntVector from draftsman.prototypes.straight_rail import StraightRail from draftsman.prototypes.curved_rail import CurvedRail from draftsman.prototypes.gate import Gate -from draftsman import utils +from draftsman.utils import ( + AABB, aabb_overlaps_aabb, aabb_overlaps_circle, point_in_aabb, point_in_circle +) from draftsman.warning import OverlappingObjectsWarning import math -from typing import Sequence +from typing import Optional import warnings @@ -23,8 +23,7 @@ class SpatialHashMap(SpatialDataStructure): Accellerates spatial queries of :py:class:`~.EntityCollection`. """ - def __init__(self, cell_size=8): - # type: (int) -> None + def __init__(self, cell_size: int=8) -> None: """ Create a new :py:class:`.SpatialHashMap`. @@ -33,8 +32,7 @@ def __init__(self, cell_size=8): self.cell_size = cell_size self.map = {} - def add(self, item): - # type: (SpatialLike, bool) -> None + def add(self, item: SpatialLike, merge: bool=False) -> None: item_region = item.get_world_bounding_box() # Get cells based off of collision_box @@ -45,16 +43,14 @@ def add(self, item): except KeyError: self.map[cell_coord] = [item] - def recursive_add(self, item): - # type: (SpatialLike, bool) -> None + def recursive_add(self, item: SpatialLike, merge: bool=False) -> None: if hasattr(item, "entities"): for sub_item in item.entities: self.recursive_add(sub_item) else: self.add(item) - def remove(self, item): - # type: (SpatialLike) -> None + def remove(self, item: SpatialLike) -> None: cell_coords = self._cell_coords_from_aabb(item.get_world_bounding_box()) for cell_coord in cell_coords: try: @@ -77,17 +73,7 @@ def clear(self): # type: () -> None self.map.clear() - def handle_overlapping(self, item, merge): - # type: (SpatialLike, bool) -> None - """ - Handles overlapping items if ``item`` were to be added to this hashmap. - Issues overlapping objects warnings and merges entities if desired. - - .. Warning:: - - This function may not be permanent, or it may move somewhere else in - future versions. - """ + def handle_overlapping(self, item: SpatialLike, merge: bool) -> None: if isinstance(item, EntityCollection): # Recurse through all subentities merged_entities = [] # keep track of merged entities, if any @@ -169,8 +155,7 @@ def handle_overlapping(self, item, merge): return item - def get_all_entities(self): - # type: () -> list[SpatialLike] + def get_all_entities(self) -> list[SpatialLike]: items = [] for cell_coord in self.map: for item in self.map[cell_coord]: @@ -178,15 +163,14 @@ def get_all_entities(self): return items - def get_in_radius(self, radius, point, limit=None): - # type: (float, Sequence[float], int) -> list[SpatialLike] + def get_in_radius(self, radius: float, point: PrimitiveVector, limit: Optional[int]=None) -> list[SpatialLike]: cell_coords = self._cell_coords_from_radius(radius, point) items = [] for cell_coord in cell_coords: if cell_coord in self.map: for item in self.map[cell_coord]: item_pos = (item.global_position.x, item.global_position.y) - if utils.point_in_circle(item_pos, radius, point): + if point_in_circle(item_pos, radius, point): if limit is not None and len(items) >= limit: break # Make sure we dont add the same item multiple times if @@ -198,27 +182,25 @@ def get_in_radius(self, radius, point, limit=None): return items - def get_on_point(self, point, limit=None): - # type: (utils.Point, int) -> list[SpatialLike] + def get_on_point(self, point: PrimitiveVector, limit: Optional[int]=None) -> list[SpatialLike]: cell_coord = self._map_coords(point) items = [] if cell_coord in self.map: for item in self.map[cell_coord]: - if utils.point_in_aabb(point, item.get_world_bounding_box()): + if point_in_aabb(point, item.get_world_bounding_box()): if limit is not None and len(items) >= limit: break items.append(item) return items - def get_in_area(self, area, limit=None): - # type: (utils.AABB, int) -> list[SpatialLike] + def get_in_area(self, area: AABB, limit: Optional[int]=None) -> list[SpatialLike]: cell_coords = self._cell_coords_from_aabb(area) items = [] for cell_coord in cell_coords: if cell_coord in self.map: for item in self.map[cell_coord]: - if utils.aabb_overlaps_aabb(item.get_world_bounding_box(), area): + if aabb_overlaps_aabb(item.get_world_bounding_box(), area): if limit is not None and len(items) >= limit: break # Make sure we dont add the same item multiple times if @@ -230,8 +212,7 @@ def get_in_area(self, area, limit=None): return items - def _map_coords(self, point): - # type: (list[float]) -> tuple[int, int] + def _map_coords(self, point: PrimitiveVector) -> PrimitiveIntVector: """ Get the internal map-coordinates from the world-space coordinates. @@ -242,10 +223,9 @@ def _map_coords(self, point): int(math.floor(point[1] / self.cell_size)), ) - def _cell_coords_from_aabb(self, aabb): - # type: (utils.AABB) -> list[tuple[int, int]] + def _cell_coords_from_aabb(self, aabb: AABB) -> list[PrimitiveIntVector]: """ - Get a list of map-coordinates that correspond to a world-space AABB. + Get a list of map cell coordinates that correspond to a world-space AABB. :param aabb: AABB to search, or ``None``. @@ -270,8 +250,7 @@ def _cell_coords_from_aabb(self, aabb): return cells - def _cell_coords_from_radius(self, radius, point): - # type: (float, utils.Point) -> list[tuple[int, int]] + def _cell_coords_from_radius(self, radius: float, point: PrimitiveVector) -> list[PrimitiveIntVector]: """ Get a list of map-coordinates that correspond to a world-space circle. @@ -290,13 +269,13 @@ def _cell_coords_from_radius(self, radius, point): cells = [] for j in range(grid_min[1], grid_min[1] + grid_height): for i in range(grid_min[0], grid_min[0] + grid_width): - cell_aabb = utils.AABB( + cell_aabb = AABB( i * self.cell_size, j * self.cell_size, (i + 1) * self.cell_size, (j + 1) * self.cell_size, ) - if utils.aabb_overlaps_circle(cell_aabb, radius, point): + if aabb_overlaps_circle(cell_aabb, radius, point): cells.append((i, j)) return cells diff --git a/draftsman/classes/spatial_like.py b/draftsman/classes/spatial_like.py index 069bd75..1f7b22f 100644 --- a/draftsman/classes/spatial_like.py +++ b/draftsman/classes/spatial_like.py @@ -3,6 +3,7 @@ from draftsman.classes.collision_set import CollisionSet from draftsman.classes.vector import Vector +from draftsman.utils import AABB import abc import copy @@ -17,8 +18,7 @@ class SpatialLike: """ @abc.abstractproperty - def position(self): # pragma: no coverage - # type: () -> Vector + def position(self) -> Vector: # pragma: no coverage """ Position of the object, expressed in local space. Local space can be either global space (if the EntityLike exists in a Blueprint at a root @@ -27,16 +27,14 @@ def position(self): # pragma: no coverage pass @abc.abstractproperty - def global_position(self): # pragma: no coverage - # type: () -> Vector + def global_position(self) -> Vector: # pragma: no coverage """ Position of the object, expressed in global space (world space). """ pass @abc.abstractproperty - def collision_set(self): # pragma: no coverage - # type: () -> CollisionSet + def collision_set(self) -> CollisionSet: # pragma: no coverage """ Set of :py:class:`.CollisionShape` where the Entity's position acts as their origin. @@ -44,16 +42,14 @@ def collision_set(self): # pragma: no coverage pass @abc.abstractproperty - def collision_mask(self): # pragma: no coverage - # type: () -> set + def collision_mask(self) -> set[str]: # pragma: no coverage """ A set of strings representing the collision layers that this object collides with. """ pass - def get_world_bounding_box(self): - # type () -> AABB + def get_world_bounding_box(self) -> AABB: """ Gets the world-space coordinates AABB that completely encompasses the ``collision_set`` of this SpatialLike. Behaves similarly to the old @@ -73,8 +69,7 @@ def get_world_bounding_box(self): return bounding_box - def get_world_collision_set(self): - # type: () -> CollisionSet + def get_world_collision_set(self) -> CollisionSet: """ Get's the world-space coordinate CollisionSet of the object, or the collection of all shapes that this EntityLike interacts with. diff --git a/draftsman/classes/tile.py b/draftsman/classes/tile.py index 83c2825..ce80664 100644 --- a/draftsman/classes/tile.py +++ b/draftsman/classes/tile.py @@ -10,38 +10,74 @@ """ from draftsman.classes.collision_set import CollisionSet +from draftsman.classes.exportable import ( + Exportable, + ValidationResult, + attempt_and_reissue, +) from draftsman.classes.spatial_like import SpatialLike -from draftsman.classes.vector import Vector -from draftsman.error import InvalidTileError, DraftsmanError -from draftsman.signatures import DraftsmanBaseModel, IntPosition +from draftsman.classes.vector import Vector, PrimitiveVector +from draftsman.constants import ValidationMode +from draftsman.error import DataFormatError, DraftsmanError +from draftsman.signatures import DraftsmanBaseModel, IntPosition, TileName from draftsman.utils import AABB import draftsman.data.tiles as tiles -import difflib -from pydantic import ConfigDict, GetCoreSchemaHandler +from pydantic import ( + ConfigDict, + GetCoreSchemaHandler, + Field, + PrivateAttr, + ValidationError, + field_serializer, +) from pydantic_core import CoreSchema, core_schema -from typing import Any, TYPE_CHECKING, Union, Tuple +from typing import Any, Literal, Optional, Union, TYPE_CHECKING if TYPE_CHECKING: # pragma: no coverage - from draftsman.classes.blueprint import Blueprint + from draftsman.classes.collection import TileCollection _TILE_COLLISION_SET = CollisionSet([AABB(0, 0, 1, 1)]) -class Tile(SpatialLike): +class Tile(SpatialLike, Exportable): """ Tile class. Used for keeping track of tiles in Blueprints. """ class Format(DraftsmanBaseModel): - name: str - position: IntPosition + _position: Vector = PrivateAttr() + + name: TileName = Field(..., description="""The Factorio ID of the tile.""") + position: IntPosition = Field( + IntPosition(x=0, y=0), + description=""" + The position of the tile in the blueprint. Specified in integer, + tile coordinates. + """, + ) + + @field_serializer("position") + def serialize_position(self, _): + # TODO: make this use global position for when we add this to groups + return self._position.to_dict() - model_config = ConfigDict(title="Tile", ) + model_config = ConfigDict( + title="Tile", + ) - def __init__(self, name, position=(0, 0)): - # type: (str, Tuple[int, int]) -> None + def __init__( + self, + name: str, + position=(0, 0), + validate: Union[ + ValidationMode, Literal["none", "minimum", "strict", "pedantic"] + ] = ValidationMode.STRICT, + validate_assignment: Union[ + ValidationMode, Literal["none", "minimum", "strict", "pedantic"] + ] = ValidationMode.STRICT, + ): """ Create a new Tile with ``name`` at ``position``. ``position`` defaults to ``(0, 0)``. @@ -53,6 +89,15 @@ def __init__(self, name, position=(0, 0)): :exception IndexError: If the position does not match the correct specification. """ + self._root: __class__.Format + + super().__init__() + + self._root = __class__.Format.model_construct() + + # Setup private attributes + self._root._position = Vector(0, 0) + # Reference to parent blueprint self._parent = None @@ -62,18 +107,20 @@ def __init__(self, name, position=(0, 0)): # Tile positions are in integer grid coordinates self.position = position + self.validate_assignment = validate_assignment + + self.validate(mode=validate).reissue_all(stacklevel=3) + # ========================================================================= @property - def parent(self): - # type: () -> Blueprint + def parent(self) -> Optional["TileCollection"]: return self._parent # ========================================================================= @property - def name(self): - # type: () -> str + def name(self) -> str: """ The name of the Tile. @@ -88,18 +135,22 @@ def name(self): :exception InvalidTileError: If the set name is not a valid Factorio tile id. """ - return self._name + return self._root.name @name.setter - def name(self, value): - # type: (str) -> None - self._name = value + def name(self, value: str): + if self.validate_assignment: + result = attempt_and_reissue( + self, type(self).Format, self._root, "name", value + ) + self._root.name = result + else: + self._root.name = value # ========================================================================= @property - def position(self): - # type: () -> dict + def position(self) -> Vector: """ The position of the tile, in tile-grid coordinates. @@ -117,21 +168,19 @@ def position(self): :exception IndexError: If the set value does not match the above specification. """ - return self._position + return self._root._position @position.setter - def position(self, value): - # type: (Union[dict, list, tuple]) -> None - + def position(self, value: Union[PrimitiveVector, Vector]): if self.parent: raise DraftsmanError("Cannot move tile while it's inside a TileCollection") - self._position = Vector.from_other(value, int) + self._root._position.update_from_other(value, int) # ========================================================================= @property - def global_position(self): + def global_position(self) -> Vector: # This is redundant in this case because tiles cannot be placed inside # of Groups (yet) # However, it's still necessary. @@ -140,64 +189,18 @@ def global_position(self): # ========================================================================= @property - def collision_set(self): - # type: () -> CollisionSet + def collision_set(self) -> CollisionSet: return _TILE_COLLISION_SET # ========================================================================= @property - def collision_mask(self): - # type: () -> set - try: - return tiles.raw[self.name]["collision_mask"] - except KeyError: - return set() + def collision_mask(self) -> Optional[set]: + return tiles.raw.get(self.name, {"collision_mask": None})["collision_mask"] # ========================================================================= - def inspect(self): - # type: () -> list[Exception] - """ - Checks the tile to see if Draftsman thinks that it can be loaded in game, - and returns a list of all potential issues that Draftsman cannot fix on - it's own. Also performs any data normalization steps, if needed. - Returns an empty list if there are no issues. - - :raises InvalidTileError: If :py:attr:`name` is not recognized by - Draftsman to be a valid tile name. - - :example: - - .. code-block:: python - - tile = Tile("unknown-name") - for issue in tile.valdiate(): - if type(issue) is InvalidTileError: - tile = Tile("concrete") # swap the tile to a known one - else: # some other error - raise issue - """ - issues = [] - - if self.name not in tiles.raw: - suggestions = difflib.get_close_matches(self.name, tiles.raw, n=1) - if len(suggestions) > 0: - suggestion_string = "; did you mean '{}'?".format(suggestions[0]) - else: - suggestion_string = "" - issues.append( - InvalidTileError( - "'{}' is not a valid name for this {}{}".format( - self.name, type(self).__name__, suggestion_string - ) - ) - ) - - return issues - - def mergable_with(self, other): - # type: (Tile) -> bool + def mergable_with(self, other: "Tile") -> bool: """ Determines if two entities are mergeable, or that they can be combined into a single tile. Two tiles are considered mergable if they have the @@ -213,8 +216,7 @@ def mergable_with(self, other): and self.position == other.position ) - def merge(self, other): - # type: (Tile) -> None + def merge(self, other: "Tile"): """ Merges this tile with another one. Due to the simplicity of tiles, this does nothing as long as the merged tiles are of the same name. Allows @@ -225,14 +227,45 @@ def merge(self, other): """ pass - def to_dict(self): - # type: () -> dict - """ - Converts the Tile to its JSON-dict representation. + # def to_dict(self) -> dict: + # """ + # Converts the Tile to its JSON-dict representation. - :returns: The exported JSON-dict representation of the Tile. - """ - return {"name": self.name, "position": self.position.to_dict()} + # :returns: The exported JSON-dict representation of the Tile. + # """ + # return {"name": self.name, "position": self.position.to_dict()} + + def validate( + self, mode: ValidationMode = ValidationMode.STRICT, force: bool = False + ) -> ValidationResult: # TODO: defer to parent + mode = ValidationMode(mode) + + output = ValidationResult([], []) + + if mode is ValidationMode.NONE or (self.is_valid and not force): + return output + + context = { + "mode": mode, + "object": self, + "warning_list": [], + "assignment": False, + } + + try: + result = self.Format.model_validate( + self._root, strict=False, context=context + ) + # Reassign private attributes + result._position = self._root._position + # Acquire the newly converted data + self._root = result + except ValidationError as e: + output.error_list.append(DataFormatError(e)) + + output.warning_list += context["warning_list"] + + return output # ========================================================================= @@ -243,10 +276,11 @@ def __eq__(self, other): and self.position == other.position ) - def __repr__(self): # pragma: no coverage - # type: () -> str + def __repr__(self) -> str: # pragma: no coverage return "{}".format(self.to_dict()) - + @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: - return core_schema.no_info_after_validator_function(cls, handler(Tile.Format)) # TODO: correct annotation + def __get_pydantic_core_schema__( + cls, _, handler: GetCoreSchemaHandler + ) -> CoreSchema: # pragma: no coverage + return core_schema.no_info_after_validator_function(cls, handler(Tile.Format)) diff --git a/draftsman/classes/tile_list.py b/draftsman/classes/tile_list.py index cbabe72..2493698 100644 --- a/draftsman/classes/tile_list.py +++ b/draftsman/classes/tile_list.py @@ -10,7 +10,7 @@ from copy import deepcopy from pydantic import GetCoreSchemaHandler from pydantic_core import CoreSchema, core_schema -from typing import Any, TYPE_CHECKING +from typing import Any, Optional, TYPE_CHECKING import six if TYPE_CHECKING: # pragma: no coverage @@ -21,46 +21,48 @@ class TileList(MutableSequence): """ TODO """ - - def __init__(self, parent, initlist=None, unknown="error"): - # type: (TileCollection, list[Tile], str) -> None + + def __init__(self, parent: "TileCollection", initlist: Optional[list[Tile]]=None, unknown: str="error") -> None: """ TODO """ - self.data = [] self._parent = parent if initlist is not None: for elem in initlist: print(elem) if isinstance(elem, Tile): - self.append(elem, unknown=unknown) + self.append(elem) elif isinstance(elem, dict): name = elem.pop("name") - self.append(name, **elem, unknown=unknown) + self.append(name, **elem) else: - raise DataFormatError("TileList only takes either Tile or dict entries") + raise DataFormatError( + "TileList only takes either Tile or dict entries" + ) - def append(self, tile, copy=True, merge=False, unknown="error", **kwargs): - # type: (Tile, bool, bool, str, **dict) -> None + def append(self, tile, copy=True, merge=False, **kwargs): + # type: (Tile, bool, bool, **dict) -> None """ Appends the Tile to the end of the sequence. """ - self.insert(len(self.data), tile, copy, merge, unknown=unknown, **kwargs) + self.insert(len(self.data), tile, copy, merge, **kwargs) - def insert(self, idx, tile, copy=True, merge=False, unknown="error", **kwargs): + def insert(self, idx, tile, copy=True, merge=False, **kwargs): # type: (int, Tile, bool, bool, str, **dict) -> None """ Inserts an element into the TileList. """ - if isinstance(tile, six.string_types): - tile = Tile(six.text_type(tile), **kwargs) + if isinstance(tile, str): + tile = Tile(tile, **kwargs) elif copy: tile = deepcopy(tile) # Check tile self.check_tile(tile) + print(tile) + if self._parent: tile = self._parent.on_tile_insert(tile, merge) @@ -212,10 +214,14 @@ def __eq__(self, other): if self.data[i] != other.data[i]: return False return True - - def __repr__(self) -> str: + + def __repr__(self) -> str: # pragma: no coverage return "{}".format(self.data) - @classmethod - def __get_pydantic_core_schema__(cls, _source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema: - return core_schema.no_info_after_validator_function(cls, handler(list[Tile])) # TODO: correct annotation + # @classmethod + # def __get_pydantic_core_schema__( + # cls, _source_type: Any, handler: GetCoreSchemaHandler + # ) -> CoreSchema: + # return core_schema.no_info_after_validator_function( + # cls, handler(list[Tile]) + # ) # TODO: correct annotation diff --git a/draftsman/classes/upgrade_planner.py b/draftsman/classes/upgrade_planner.py index bb59871..fe1d469 100644 --- a/draftsman/classes/upgrade_planner.py +++ b/draftsman/classes/upgrade_planner.py @@ -15,9 +15,9 @@ from draftsman.error import DataFormatError from draftsman.signatures import ( DraftsmanBaseModel, - Icons, + Icon, Mapper, - Mappers, + MapperID, mapper_dict, uint8, uint16, @@ -98,6 +98,24 @@ def check_valid_upgrade_pair( ) ] + # The types of both need to match in order to make sense + if from_obj["type"] != to_obj["type"]: + return [ + UpgradeProhibitedWarning( + "'{}' is an {} but '{}' is an {}".format( + from_obj["name"], + from_obj["type"], + to_obj["name"], + to_obj["type"], + ) + ) + ] + + # TODO: currently we don't check for item mapping correctness + # For now we just ignore it and early exit + if from_obj["type"] == "item" and to_obj["type"] == "item": + return None + # To quote Entity prototype documentation for the "next_upgrade" key: # > "This entity may not have 'not-upgradable' flag set and must be @@ -243,7 +261,7 @@ class Settings(DraftsmanBaseModel): description=""" A string description given to this UpgradePlanner.""", ) - icons: Optional[Icons] = Field( + icons: Optional[list[Icon]] = Field( None, description=""" A set of signal pictures to associate with this @@ -257,6 +275,20 @@ class Settings(DraftsmanBaseModel): """, ) + @field_validator("icons", mode="before") + @classmethod + def normalize_icons(cls, value: Any): + if isinstance(value, Sequence): + result = [None] * len(value) + for i, signal in enumerate(value): + if isinstance(signal, str): + result[i] = {"index": i + 1, "signal": signal} + else: + result[i] = signal + return result + else: + return value + @field_validator("mappers", mode="before") @classmethod def normalize_mappers(cls, value: Any): @@ -281,14 +313,12 @@ def normalize_mappers(cls, value: Any): def ensure_mappers_valid(self, info: ValidationInfo): if not info.context or self.mappers is None: return self - elif info.context["mode"] is ValidationMode.MINIMUM: + elif info.context["mode"] <= ValidationMode.MINIMUM: return self warning_list: list = info.context["warning_list"] upgrade_planner: UpgradePlanner = info.context["object"] - print(self.mappers) - # Keep track to see if multiple entries exist with the same index occupied_indices = {} # Check each mapper @@ -296,8 +326,6 @@ def ensure_mappers_valid(self, info: ValidationInfo): # Ensure that "from" and "to" are a valid pair # We assert that index must exist in each mapper, but both "from" # and "to" may be omitted - print("\t", mapper) - print(mapper.get("from", None), mapper.get("to", None)) reasons = check_valid_upgrade_pair( mapper.get("from", None), mapper.get("to", None) ) @@ -367,11 +395,11 @@ def normalize_to_int(cls, value: Any): upgrade_planner: UpgradePlannerObject index: Optional[uint16] = Field( - None, + None, description=""" The index of the blueprint inside a parent BlueprintBook's blueprint list. Only meaningful when this object is inside a BlueprintBook. - """ + """, ) model_config = ConfigDict(title="UpgradePlanner") @@ -419,8 +447,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) @reissue_warnings def setup( @@ -483,11 +510,11 @@ def description(self, value: Optional[str]): # ========================================================================= @property - def icons(self) -> Optional[Icons]: + def icons(self) -> Optional[list[Icon]]: return self._root[self._root_item]["settings"].get("icons", None) @icons.setter - def icons(self, value: Union[list[str], Icons, None]): + def icons(self, value: Union[list[str], list[Icon], None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -547,8 +574,9 @@ def mappers(self, value: Optional[list[Union[tuple[str, str], Mapper]]]): # ========================================================================= @reissue_warnings - def set_mapping(self, from_obj, to_obj, index): - # type: (Union[str, dict], Union[str, dict], int) -> None + def set_mapping( + self, from_obj: Union[str, MapperID], to_obj: Union[str, MapperID], index: int + ): """ Sets a single mapping in the :py:class:`.UpgradePlanner`. Setting multiple mappers at the same index overwrites the entry at that index @@ -591,8 +619,12 @@ def set_mapping(self, from_obj, to_obj, index): # TODO: make backwards compatible bisect.insort(self.mappers, new_mapping, key=lambda x: x["index"]) - def remove_mapping(self, from_obj, to_obj, index=None): - # type: (Union[str, dict], Union[str, dict], int) -> None + def remove_mapping( + self, + from_obj: Union[str, MapperID], + to_obj: Union[str, MapperID], + index: Optional[int] = None, + ): """ Removes a specified upgrade planner mapping. If ``index`` is not specified, the function searches for the first occurrence where both @@ -638,8 +670,7 @@ def remove_mapping(self, from_obj, to_obj, index=None): mapper = {"from": from_obj, "to": to_obj, "index": index} self.mappers.remove(mapper) - def pop_mapping(self, index): - # type: (int) -> Mapper + def pop_mapping(self, index: int) -> Mapper: """ Removes a mapping at a specific mapper index. Note that this is not the position of the mapper in the :py:attr:`.mappers` list; it is the value diff --git a/draftsman/classes/vector.py b/draftsman/classes/vector.py index 7af5d0b..84b76fe 100644 --- a/draftsman/classes/vector.py +++ b/draftsman/classes/vector.py @@ -1,5 +1,4 @@ # vector.py -# -*- encoding: utf-8 -*- """ TODO @@ -10,7 +9,9 @@ from pydantic import BaseModel, ConfigDict from typing import Union, Callable -PrimitiveVector = tuple[int, int] +PrimitiveVector = tuple[Union[int, float], Union[int, float]] +PrimitiveIntVector = tuple[int, int] +PrimitiveFloatVector = tuple[float, float] class Vector(object): @@ -18,14 +19,7 @@ class Vector(object): A simple 2d vector class, used to aid in developent and user experience. """ - class Model(BaseModel): - x: float - y: float - - model_config = ConfigDict(extra="forbid") - - def __init__(self, x, y): - # type: (float, float, Vector) -> None + def __init__(self, x: Union[float, int], y: Union[float, int]): """ Constructs a new :py:class:`.Vector`. @@ -36,8 +30,7 @@ def __init__(self, x, y): self.update(x, y) @property - def x(self): - # type: () -> Union[float, int] + def x(self) -> Union[float, int]: """ The x-coordinate of the point. Returns either a ``float`` or an ``int``, depending on the :py:class:`.Vector` queried. @@ -49,16 +42,11 @@ def x(self): return self._data[0] @x.setter - def x(self, value): - # type: (Union[float, int]) -> None + def x(self, value: Union[float, int]): self._data[0] = value - # if self.linked: - # self.linked._data[0] = self.linked.transform_x(self._entity, value) - @property - def y(self): - # type: () -> float + def y(self) -> Union[float, int]: """ The y-coordinate of the vector. Returns either a ``float`` or an ``int``, depending on the :py:class:`.Vector` queried. @@ -70,33 +58,9 @@ def y(self): return self._data[1] @y.setter - def y(self, value): - # type: (Union[float, int]) -> None + def y(self, value: Union[float, int]): self._data[1] = value - # if self.linked: - # self.linked._data[1] = self.linked.transform_y(self._entity, value) - - def transform_x(self, x): - # type: (float) -> float - """ - Calculates the value to set the linked vector's x coordinate with. Used - to transform a change in the parent vector into a different change in - the linked vector. Defaults to just setting the x value of the linked - vector to the x value of the parent. - """ - return x - - def transform_y(self, y): - # type: (float) -> float - """ - Calculates the value to set the linked vector's y coordinate with. Used - to transform a change in the parent vector into a different change in - the linked vector. Defaults to just setting the y value of the linked - vector to the y value of the parent. - """ - return y - # ========================================================================= @staticmethod @@ -153,8 +117,7 @@ def update_from_other(self, other, type_cast=float): else: raise TypeError("Could not resolve '{}' to a Vector object".format(other)) - def to_dict(self): - # type: () -> dict + def to_dict(self) -> dict: """ Convert this vector to a Factorio-parseable dict with "x" and "y" keys. @@ -164,8 +127,7 @@ def to_dict(self): # ========================================================================= - def __getitem__(self, index): - # type: (int) -> Union[float, int] + def __getitem__(self, index: Union[str, int]) -> Union[float, int]: if index == "x": return self._data[0] elif index == "y": @@ -173,8 +135,7 @@ def __getitem__(self, index): else: return self._data[index] - def __setitem__(self, index, value): - # type: (int, Union[float, int]) -> None + def __setitem__(self, index: Union[str, int], value: Union[float, int]): if index == "x": self._data[0] = value elif index == "y": @@ -182,29 +143,27 @@ def __setitem__(self, index, value): else: self._data[index] = value - def __add__(self, other): - # type: (Union[Vector, PrimitiveVector, float, int]) -> Vector + def __add__(self, other: Union["Vector", PrimitiveVector, float, int]) -> "Vector": try: return Vector(self[0] + other[0], self[1] + other[1]) except TypeError: return Vector(self[0] + other, self[1] + other) - def __sub__(self, other): - # type: (Union[Vector, PrimitiveVector, float, int]) -> Vector + def __sub__(self, other: Union["Vector", PrimitiveVector, float, int]) -> "Vector": try: return Vector(self[0] - other[0], self[1] - other[1]) except TypeError: return Vector(self[0] - other, self[1] - other) - def __mul__(self, other): - # type: (Union[Vector, PrimitiveVector, float, int]) -> Vector + def __mul__(self, other: Union["Vector", PrimitiveVector, float, int]) -> "Vector": try: return Vector(self[0] * other[0], self[1] * other[1]) except TypeError: return Vector(self[0] * other, self[1] * other) - def __truediv__(self, other): - # type: (Union[Vector, float, int]) -> Vector + def __truediv__( + self, other: Union["Vector", PrimitiveVector, float, int] + ) -> "Vector": try: return Vector(self[0] / other[0], self[1] / other[1]) except TypeError: @@ -212,29 +171,25 @@ def __truediv__(self, other): __div__ = __truediv__ - def __floordiv__(self, other): - # type: (Union[Vector, float, int]) -> Vector + def __floordiv__( + self, other: Union["Vector", PrimitiveVector, float, int] + ) -> "Vector": try: return Vector(self[0] // other[0], self[1] // other[1]) except TypeError: return Vector(self[0] // other, self[1] // other) - def __eq__(self, other): - # type: (Vector) -> bool + def __eq__(self, other) -> bool: return isinstance(other, Vector) and self.x == other.x and self.y == other.y - def __abs__(self): - # type: () -> Vector + def __abs__(self) -> "Vector": return Vector(abs(self[0]), abs(self[1])) - def __round__(self, precision=0): - # type: (int) -> Vector + def __round__(self, precision: int = 0) -> "Vector": return Vector(round(self[0], precision), round(self[1], precision)) - def __str__(self): # pragma: no coverage - # type: () -> str + def __str__(self) -> str: # pragma: no coverage return "({}, {})".format(self._data[0], self._data[1]) - def __repr__(self): # pragma: no coverage - # type: () -> str + def __repr__(self) -> str: # pragma: no coverage return "({}, {})".format(self._data[0], self._data[1]) diff --git a/draftsman/constants.py b/draftsman/constants.py index 1b16b41..b91e416 100644 --- a/draftsman/constants.py +++ b/draftsman/constants.py @@ -8,6 +8,7 @@ from datetime import timedelta from enum import IntEnum, Enum +from functools import total_ordering import math from pydantic_core import core_schema @@ -153,27 +154,28 @@ def to_vector(self, magnitude=1): return mapping[self] * magnitude -class OrientationMeta(type): - _mapping = { - "NORTH": 0.0, - "NORTHEAST": 0.125, - "EAST": 0.25, - "SOUTHEAST": 0.375, - "SOUTH": 0.5, - "SOUTHWEST": 0.625, - "WEST": 0.75, - "NORTHWEST": 0.875, - } - - def __getattr__(cls, name): - if name in cls._mapping: - return Orientation(cls._mapping[name]) - else: - super().__getattr__(name) +# class OrientationMeta(type): +# _mapping = { +# "NORTH": 0.0, +# "NORTHEAST": 0.125, +# "EAST": 0.25, +# "SOUTHEAST": 0.375, +# "SOUTH": 0.5, +# "SOUTHWEST": 0.625, +# "WEST": 0.75, +# "NORTHWEST": 0.875, +# } + +# def __getattr__(cls, name): +# if name in cls._mapping: +# return Orientation(cls._mapping[name]) +# else: +# super().__getattr__(name) - # NORTH = Orientation(0.0) +# # NORTH = Orientation(0.0) +@total_ordering class Orientation(float): """ Factorio orientation enum. Represents the direction an object is facing with @@ -198,17 +200,17 @@ class Orientation(float): """ # Note: These are overwritten with Orientation() instances after definition - NORTH = 0.0 - NORTHEAST = 0.125 - EAST = 0.25 - SOUTHEAST = 0.375 - SOUTH = 0.5 - SOUTHWEST = 0.625 - WEST = 0.75 - NORTHWEST = 0.875 - - def __init__(self, value): - self._value_ = value + NORTH: "Orientation" = 0.0 + NORTHEAST: "Orientation" = 0.125 + EAST: "Orientation" = 0.25 + SOUTHEAST: "Orientation" = 0.375 + SOUTH: "Orientation" = 0.5 + SOUTHWEST: "Orientation" = 0.625 + WEST: "Orientation" = 0.75 + NORTHWEST: "Orientation" = 0.875 + + def __init__(self, value: float): + self._value_ = value % 1.0 _reverse_mapping = { 0.0: "NORTH", 0.125: "NORTHEAST", @@ -224,8 +226,7 @@ def __init__(self, value): else: self._name_ = None - def opposite(self): - # type: () -> Orientation + def opposite(self) -> "Orientation": """ Returns the direction opposite this one. For cardinal four-way and eight- way directions calling this function should always return the "true" @@ -243,10 +244,9 @@ def opposite(self): :returns: A new :py:class:`Orientation` object. """ - return self + 0.5 + return Orientation((self._value_ + 0.5) % 1.0) - def to_direction(self, eight_way=False): - # type: (bool) -> Direction + def to_direction(self, eight_way: bool = False) -> Direction: """ Converts the orientation to a :py:class:`Direction` instance. If the orientation is imprecise, the orientation will be rounded to either the @@ -262,8 +262,7 @@ def to_direction(self, eight_way=False): else: return Direction(round(self._value_ * 4) * 2) - def to_vector(self, magnitude=1): - # type: (float) -> Vector + def to_vector(self, magnitude=1) -> Vector: """ Converts a :py:class:`Orientation` into an equivalent 2-dimensional vector, for various linear operations. Returned vectors are unit-length, @@ -278,19 +277,60 @@ def to_vector(self, magnitude=1): :param magnitude: The magnitude (total length) of the vector to create. - :returns: A new :py:class:`Vector` object pointing in the correct + :returns: A new :py:class:`Vector` object pointing in the corresponding direction. """ angle = self._value_ * math.pi * 2 return Vector(math.sin(angle), -math.cos(angle)) * magnitude - def __add__(self, other): - other = Orientation(other) - return Orientation((self._value_ + other._value_) % 1.0) + # ========================================================================= + + def __add__(self, other) -> "Orientation": + if isinstance(other, Orientation): + return Orientation(self._value_ + other._value_) + elif isinstance(other, float): + return Orientation(self._value_ + other) + else: + return NotImplemented + + def __radd__(self, other) -> "Orientation": + if isinstance(other, float): + return Orientation(other + self._value_) + else: + return NotImplemented + + def __sub__(self, other) -> "Orientation": + if isinstance(other, Orientation): + return Orientation(self._value_ - other._value_) + elif isinstance(other, float): + return Orientation(self._value_ - other) + else: + return NotImplemented - def __sub__(self, other): - other = Orientation(other) - return Orientation((self._value_ - other._value_) % 1.0) + def __rsub__(self, other) -> "Orientation": + if isinstance(other, float): + return Orientation(other - self._value_) + else: + return NotImplemented + + def __eq__(self, other) -> bool: + if isinstance(other, Orientation): + return self._value_ == other._value_ + elif isinstance(other, float): + return self._value_ == other + else: + return NotImplemented + + def __lt__(self, other) -> bool: + if isinstance(other, Orientation): + return self._value_ < other._value_ + elif isinstance(other, float): + return self._value_ < other + else: + return NotImplemented + + def __hash__(self) -> int: + return id(self) >> 4 # Default def __repr__(self) -> str: # Matches the format of Enum unless the value isn't one of the special @@ -300,7 +340,7 @@ def __repr__(self) -> str: else: special_name = "" return "<%s%s: %r>" % (self.__class__.__name__, special_name, self._value_) - + @classmethod def __get_pydantic_core_schema__(cls, _): return core_schema.float_schema() @@ -419,7 +459,7 @@ class TileSelectionMode(IntEnum): ONLY = 3 -class Ticks(IntEnum): +class Ticks(int, Enum): """ Constant values that correspond to the number of Factorio ticks for that measure of time at normal game-speed. @@ -513,16 +553,31 @@ class WireColor(str, Enum): GREEN = "green" -class ValidationMode(str, Enum): +@total_ordering +class ValidationMode(Enum): """ The manner in which to validate a given Draftsman object. TODO """ - NONE = ("none",) + NONE = "none" MINIMUM = "minimum" STRICT = "strict" PEDANTIC = "pedantic" - def __bool__(self): + def __bool__(self) -> bool: return self is not ValidationMode.NONE + + def __eq__(self, other): + if isinstance(other, ValidationMode): + return self._member_names_.index(self.name) == self._member_names_.index( + other.name + ) + return NotImplemented + + def __gt__(self, other): + if isinstance(other, ValidationMode): + return self._member_names_.index(self.name) > self._member_names_.index( + other.name + ) + return NotImplemented diff --git a/draftsman/data/entities.pkl b/draftsman/data/entities.pkl index e7ba73a..7b5f1ad 100644 Binary files a/draftsman/data/entities.pkl and b/draftsman/data/entities.pkl differ diff --git a/draftsman/data/fluids.pkl b/draftsman/data/fluids.pkl index f34538f..2c5b185 100644 Binary files a/draftsman/data/fluids.pkl and b/draftsman/data/fluids.pkl differ diff --git a/draftsman/data/fluids.py b/draftsman/data/fluids.py index 20ebe2d..87c744a 100644 --- a/draftsman/data/fluids.py +++ b/draftsman/data/fluids.py @@ -16,11 +16,29 @@ raw: dict[str, dict] = _data[0] -def add_fluid(name, order, default_temperature, maximum_temperature=None, **kwargs): +def add_fluid(name: str, order: str = None, **kwargs): """ - Add a fluid. TODO + Add a new fluid, or modify the properties of an existing fluid. Useful for + simulating a modded environment without having to fully supply an entire + mod configuration for simple scripts. + + TODO """ - pass # TODO + existing_data = raw.get(name, {}) + default_temperature = existing_data.get( + "default_temperature", kwargs.get("default_temperature", 25) + ) + raw[name] = { + **existing_data, + "type": "fluid", + "name": name, + "order": order if order is not None else "", + "default_temperature": default_temperature, + **kwargs, + } + # TODO: this should also update signals + # TODO: what if the user sets auto-barrel to true in this function? Ideally + # it would also generate barreling recipes and update them accordingly def get_temperature_range(fluid_name: str) -> tuple[float, float]: diff --git a/draftsman/data/instruments.pkl b/draftsman/data/instruments.pkl index 99c80f8..8036cae 100644 Binary files a/draftsman/data/instruments.pkl and b/draftsman/data/instruments.pkl differ diff --git a/draftsman/data/instruments.py b/draftsman/data/instruments.py index 7d8f43d..2b5be4b 100644 --- a/draftsman/data/instruments.py +++ b/draftsman/data/instruments.py @@ -9,14 +9,45 @@ import importlib_resources as pkg_resources # type: ignore from draftsman import data +from draftsman.data.entities import programmable_speakers with pkg_resources.open_binary(data, "instruments.pkl") as inp: _data: list = pickle.load(inp) raw: dict[str, list[dict]] = _data[0] - index: dict[str, dict[str, dict[str, int]]] = _data[1] - names: dict[str, dict[int, dict[int, str]]] = _data[2] + index_of: dict[str, dict[str, dict[str, int]]] = _data[1] + name_of: dict[str, dict[int, dict[int, str]]] = _data[2] -def add_instrument(entity: str, name: str, notes: list[str]): - raise NotImplementedError +def add_instrument( + instrument_name: str, + notes: list[str], + instrument_index: int = None, + entity_name: str = "programmable-speaker", +): + """ + TODO + """ + if entity_name not in programmable_speakers: + raise TypeError( + "Cannot add instrument to unknown programmable speaker '{}'".format( + entity_name + ) + ) + + # Add to the end of the instruments list if index is omitted + instrument_index = ( + len(raw[entity_name]) if instrument_index is None else instrument_index + ) + + # Update `raw` + new_entry = {"name": instrument_name, "notes": [{"name": note} for note in notes]} + raw[entity_name].insert(instrument_index, new_entry) + + # Update `index` + index_entry = {notes[i]: i for i in range(len(notes))} + index_of[entity_name][instrument_name] = {"self": instrument_index, **index_entry} + + # Update `names` + names_entry = {i: notes[i] for i in range(len(notes))} + name_of[entity_name][instrument_index] = {"self": instrument_name, **names_entry} diff --git a/draftsman/data/items.pkl b/draftsman/data/items.pkl index df443fb..37e8c00 100644 Binary files a/draftsman/data/items.pkl and b/draftsman/data/items.pkl differ diff --git a/draftsman/data/items.py b/draftsman/data/items.py index 346d0a2..b46bfb7 100644 --- a/draftsman/data/items.py +++ b/draftsman/data/items.py @@ -22,5 +22,113 @@ ) -def add_item(name: str, subgroup: str, group: str): - raise NotImplementedError +def add_group(name: str, order: str = "", subgroups=[], **kwargs): + """ + TODO + """ + # Prioritize existing data if present + existing_data = raw.get(name, {}) + order = existing_data.get("order", order) + items = existing_data.get("subgroups", subgroups) + # Assign + new_data = { + **existing_data, + "type": "item-group", + "name": name, + "order": order, + "subgroups": subgroups, + **kwargs, + } + # TODO: sorted insert + # This is harder than it sounds though + groups[name] = new_data + + +def add_subgroup(name: str, group: str, items=[], order: str = "", **kwargs): + """ + TODO + """ + # Prioritize existing data if present + existing_data = raw.get(name, {}) + order = existing_data.get("order", order) + items = existing_data.get("items", items) + # Assign + new_data = { + **existing_data, + "type": "item-subgroup", + "name": name, + "order": order, + "items": items, + **kwargs, + } + + if group not in groups: + raise TypeError( + "Unknown item group '{}'; if you want to add a new item group, call `items.add_group(mame='{}', ...)`" + ) + else: + # TODO: sorted insert + # This is harder than it sounds though + subgroups[name] = new_data + groups[group]["subgroups"].append(new_data) + + +def add_item( + name: str, stack_size: int, order: str = "", subgroup: str = "other", **kwargs +): + """ + Add a new item, or modify the properties of an existing item. Useful for + simulating a modded environment without having to fully supply an entire + mod configuration for simple scripts. + + If you specify a subgroup name that does not exist in `items.subgroups`, + then this function will create a new item subgroup and populate this dict + as well with a dummy value. Note that this new subgroup will + + If you want to add an item to custom groups, it likely makes more sense to + run the functions `items.add_group` and `items.add_subgroup` with the + data you want before calling this function. + + Any modifications to the environment only persist for the remainder of that + session. + + TODO + """ + # Prioritize existing data if present + existing_data = raw.get(name, {}) + order = existing_data.get("order", order) + subgroup = existing_data.get("subgroup", subgroup) + # Assign + new_data = { + **existing_data, + "type": "item", + "name": name, + "stack_size": stack_size, + "order": order, + "subgroup": subgroup, + **kwargs, + } + + if subgroup not in subgroups: + raise TypeError( + "Unknown item subgroup '{}'; if you want to add a new item subgroup, call `items.add_subgroup(mame='{}', ...)`" + ) + else: + # TODO: sorted insert + # This is harder than it sounds though + raw[name] = new_data + subgroups[subgroup]["items"].append(new_data) + + # TODO: this should also update signals + + +def get_stack_size(item_name: str) -> int: + """ + Returns the stack size of the associated item. If ``item_name`` is not + recognized as a known item, then this function returns ``None``. + + :param item_name: The name of the item to get the stack size from. + :returns: The amount of this item that can fit inside a single inventory + slot. + """ + return raw.get(item_name, {"stack_size": None})["stack_size"] diff --git a/draftsman/data/modules.pkl b/draftsman/data/modules.pkl index e7fa769..46a4a8e 100644 Binary files a/draftsman/data/modules.pkl and b/draftsman/data/modules.pkl differ diff --git a/draftsman/data/modules.py b/draftsman/data/modules.py index 861760c..bf87214 100644 --- a/draftsman/data/modules.py +++ b/draftsman/data/modules.py @@ -18,30 +18,62 @@ categories: dict[str, list[str]] = _data[1] -def add_module(name: str, category: str): - raise NotImplementedError +def add_module_category(name: str, order: str = ""): + """ + TODO + """ + # TODO: insert sorted + categories[name] = [] + + +def add_module(module_name: str, category_name: str, **kwargs): + """ + TODO + """ + if category_name not in categories: + raise TypeError( + "Cannot add new module to unknown category '{}'".format(category_name) + ) + + existing_data = raw.get(module_name, {}) + effect = existing_data.get("effect", kwargs.pop("effect", {})) + tier = existing_data.get("tier", kwargs.pop("tier", 0)) + # Add to `raw` + new_entry = { + **existing_data, + "name": module_name, + "category": category_name, + "effect": effect, + "tier": tier, + **kwargs, + } + raw[module_name] = new_entry + # Add to `categories` + # TODO: insert sorted + categories[category_name].append(module_name) def get_modules_from_effects(allowed_effects: set[str], recipe: str = None) -> set[str]: """ - Given a list of string effect names, provide the list of available modules + Given a set of string effect names, provide the set of available modules under the current Draftsman configuration that would fit in an entity with - those effects. + those effects. If a recipe is provided, limit the available modules to ones + that can only be used with that recipe selected. """ if allowed_effects is None: return None output = set() for module_name, module in raw.items(): if recipe is not None: - # Skip addint this module if the recipe provided does not fit within + # Skip adding this module if the recipe provided does not fit within # this module's limitations if "limitation" in module and recipe not in module["limitation"]: continue - elif ( + elif ( # pragma: no branch "limitation_blacklist" in module and recipe in module["limitation_blacklist"] ): - continue + continue # pragma: no coverage # I think the effects module has to be a subset of the allowed effects # in order to be included if set(module["effect"]).issubset(allowed_effects): diff --git a/draftsman/data/recipes.pkl b/draftsman/data/recipes.pkl index e35d348..b63fd48 100644 Binary files a/draftsman/data/recipes.pkl and b/draftsman/data/recipes.pkl differ diff --git a/draftsman/data/recipes.py b/draftsman/data/recipes.py index e237af2..81591cf 100644 --- a/draftsman/data/recipes.py +++ b/draftsman/data/recipes.py @@ -1,7 +1,5 @@ # recipes.py -# -*- encoding: utf-8 -*- -import os import pickle try: # pragma: no coverage @@ -21,7 +19,7 @@ for_machine: dict[str, list[str]] = _data[2] -def add_recipe(name: str, ingredients: list[str], result: str): +def add_recipe(name: str, ingredients: list[str], result: str, **kwargs): raise NotImplementedError # TODO diff --git a/draftsman/data/signals.pkl b/draftsman/data/signals.pkl index 6c55621..70be279 100644 Binary files a/draftsman/data/signals.pkl and b/draftsman/data/signals.pkl differ diff --git a/draftsman/data/tiles.pkl b/draftsman/data/tiles.pkl index 4925d79..66c30fb 100644 Binary files a/draftsman/data/tiles.pkl and b/draftsman/data/tiles.pkl differ diff --git a/draftsman/entity.py b/draftsman/entity.py index fca2bdf..b3d594c 100644 --- a/draftsman/entity.py +++ b/draftsman/entity.py @@ -6,6 +6,7 @@ """ from draftsman.classes.entity import Entity +from draftsman.constants import ValidationMode from draftsman.error import InvalidEntityError # fmt: off @@ -72,13 +73,12 @@ from draftsman.prototypes.player_port import PlayerPort, player_ports # fmt: on -from draftsman.data import entities +from typing import Literal -import difflib - -def new_entity(name, unknown="error", **kwargs): - # type: (str, str, **dict) -> Entity +def new_entity( + name: str, if_unknown: Literal["error", "ignore", "accept"] = "error", **kwargs +): """ Factory function for creating a new :py:cls:`Entity`. The class used will be based on the entity's name, so ``new_entity("wooden-chest")`` will return a @@ -114,7 +114,7 @@ def new_entity(name, unknown="error", **kwargs): if name in underground_belts: return UndergroundBelt(name, **kwargs) if name in splitters: - return Splitter(name, **kwargs) + return Splitter(name, **kwargs) # etc. if name in inserters: return Inserter(name, **kwargs) if name in filter_inserters: @@ -228,23 +228,38 @@ def new_entity(name, unknown="error", **kwargs): if name in player_ports: return PlayerPort(name, **kwargs) - if unknown == "ignore": + # At this point, the name is unrecognized by the current environment: + if if_unknown == "error": + # Raise an issue where this entity is not known; useful in cases where + # the user wants to make sure that Draftsman knows about every entity in + # a modded blueprint, for example. + raise InvalidEntityError("Unknown entity '{}'".format(name)) + elif if_unknown == "ignore": + # Simply return nothing; if importing from a blueprint string, any + # unrecognized entity will simply be omitted from the Draftsman + # blueprint; matches the game's behavior. return None - elif unknown == "error": - # TODO: this is pretty slow all things considered. It might make more - # sense to try: ... constructing a entity of each type with a name, and - # throw a InvalidEntityError if it has no suggestions, and a different - # error (PossibleInvalidEntityError) if it does; then catch the Invalid - # EntityError and let the PossibleInvalidEntityError raise - suggestions = difflib.get_close_matches(name, entities.all, n=1) - if len(suggestions) > 0: - suggestion_string = "; did you mean '{}'".format(suggestions[0]) - else: - suggestion_string = "" - raise InvalidEntityError( - "'{}' is not a recognized entity{}".format(name, suggestion_string) - ) - elif unknown == "pass": - pass # TODO + elif if_unknown == "accept": + # Otherwise, we want Draftsman to at least try to parse it and serialize + # it, if not entirely validate it. Thus, we construct a generic instance + # of `Entity`and return that. + result = Entity(name, similar_entities=None, **kwargs) + + # Mark this class as unknown format, so some validation checks are + # omitted + result._unknown_format = True + + # Of course, since entity is normally a base class, we have to do a + # little magic to make it behave similar to all other classes + validate_assignment = kwargs.get("validate_assignment", ValidationMode.STRICT) + result.validate_assignment = validate_assignment + + validate = kwargs.get("validate", ValidationMode.STRICT) + result.validate(mode=validate).reissue_all(stacklevel=3) + + return result else: - raise ValueError("Invalid value for keyword 'unknown' ({})".format(unknown)) + raise ValueError( + "Unknown parameter value '{}' for `unknown`; must be one of 'error', 'ignore', or 'accept'", + if_unknown, + ) diff --git a/draftsman/env.py b/draftsman/env.py index b6d04ef..575061d 100644 --- a/draftsman/env.py +++ b/draftsman/env.py @@ -1205,13 +1205,14 @@ def extract_modules(lua, data_location, verbose, sort_tuple): unsorted_modules_raw = {} for module in modules: unsorted_modules_raw[module] = modules[module] - module_type = modules[module]["category"] - out_categories[module_type].append(module) raw_order = get_order(unsorted_modules_raw, *sort_tuple) modules_raw = OrderedDict() for name in raw_order: modules_raw[name] = unsorted_modules_raw[name] + # Create the categories using the (now sorted) modules + module_type = unsorted_modules_raw[name]["category"] + out_categories[module_type].append(name) with open(os.path.join(data_location, "modules.pkl"), "wb") as out: pickle.dump([modules_raw, out_categories], out, 2) diff --git a/draftsman/extras.py b/draftsman/extras.py index ca69c68..552b085 100644 --- a/draftsman/extras.py +++ b/draftsman/extras.py @@ -1,18 +1,19 @@ # extras.py -from draftsman.classes.blueprint import Blueprint +from draftsman.classes.collection import EntityCollection +from draftsman.entity import TransportBelt, UndergroundBelt, Splitter from draftsman.constants import Direction +from typing import Union +from typing import cast as typing_cast -def reverse_belts(blueprint): - # type: (Blueprint) -> None + +def reverse_belts(blueprint: EntityCollection) -> None: """ - Modifies the passed in blueprintable in place to swap the direction of all - belts. + Modifies the passed in blueprint or group in place to swap the direction of + all belts. Does more than simply flipping each belt direction by properly + handling curves. """ - if blueprint.item not in {"blueprint"}: - return - # If a transport belt is pointed to by one other belt going in a different # direction, then that's a curved belt that has special behavior # Otherwise, the simple case is to just make the direction the opposite @@ -24,7 +25,13 @@ def reverse_belts(blueprint): # another belt_types = {"transport-belt", "underground-belt", "splitter"} for i, entity in enumerate(blueprint.entities): - # If it's a belt and not in the direction map, add it + # If not a belt, ignore it + if entity.type not in belt_types: + continue + + entity = typing_cast(Union[TransportBelt, UndergroundBelt, Splitter], entity) + + # If it's not in the direction map, add it if entity.type in belt_types and i not in direction_map: direction_map[i] = {"direction": entity.direction, "pointed_by": []} diff --git a/draftsman/prototypes/accumulator.py b/draftsman/prototypes/accumulator.py index ab26d17..80fa8d7 100644 --- a/draftsman/prototypes/accumulator.py +++ b/draftsman/prototypes/accumulator.py @@ -45,7 +45,7 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -74,8 +74,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/arithmetic_combinator.py b/draftsman/prototypes/arithmetic_combinator.py index 90151c0..a75afa2 100644 --- a/draftsman/prototypes/arithmetic_combinator.py +++ b/draftsman/prototypes/arithmetic_combinator.py @@ -93,7 +93,7 @@ def ensure_upper(cls, value: Optional[str]): def ensure_no_forbidden_signals(self, info: ValidationInfo): if not info.context: return self - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return self warning_list: list = info.context["warning_list"] @@ -122,7 +122,7 @@ def ensure_no_forbidden_signals(self, info: ValidationInfo): def ensure_proper_each_configuration(self, info: ValidationInfo): if not info.context: return self - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return self warning_list: list = info.context["warning_list"] @@ -185,8 +185,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -216,8 +216,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -264,6 +263,11 @@ def first_operand(self) -> Union[SignalID, int32, None]: @first_operand.setter def first_operand(self, value: Union[str, SignalID, int32, None]): + if self.control_behavior.arithmetic_conditions is None: + self.control_behavior.arithmetic_conditions = ( + self.Format.ControlBehavior.ArithmeticConditions() + ) + if value is None: # Default self.control_behavior.arithmetic_conditions.first_signal = None self.control_behavior.arithmetic_conditions.first_constant = None @@ -296,7 +300,6 @@ def first_operand(self, value: Union[str, SignalID, int32, None]): def operation( self, ) -> Literal["*", "/", "+", "-", "%", "^", "<<", ">>", "AND", "OR", "XOR", None]: - # type: () -> str """ The operation of the ``ArithmeticCombinator`` Can be one of: @@ -314,9 +317,9 @@ def operation( :exception TypeError: If set to anything other than one of the values specified above. """ - # arithmetic_conditions = self.control_behavior.get("arithmetic_conditions", None) - # if not arithmetic_conditions: - # return None + arithmetic_conditions = self.control_behavior.get("arithmetic_conditions", None) + if not arithmetic_conditions: + return None return self.control_behavior.arithmetic_conditions.operation @@ -327,6 +330,11 @@ def operation( "*", "/", "+", "-", "%", "^", "<<", ">>", "AND", "OR", "XOR", None ], ): + if self.control_behavior.arithmetic_conditions is None: + self.control_behavior.arithmetic_conditions = ( + self.Format.ControlBehavior.ArithmeticConditions() + ) + if self.validate_assignment: result = attempt_and_reissue( self, @@ -379,6 +387,11 @@ def second_operand(self) -> Union[SignalID, int32, None]: @second_operand.setter def second_operand(self, value: Union[str, SignalID, int32, None]): + if self.control_behavior.arithmetic_conditions is None: + self.control_behavior.arithmetic_conditions = ( + self.Format.ControlBehavior.ArithmeticConditions() + ) + if value is None: # Default self.control_behavior.arithmetic_conditions.second_signal = None self.control_behavior.arithmetic_conditions.second_constant = None @@ -428,14 +441,19 @@ def output_signal(self) -> Optional[SignalID]: :py:attr:`.first_operand` nor :py:attr:`.second_operand` is set to ``"signal-each"``. """ - # arithmetic_conditions = self.control_behavior.arithmetic_conditions - # if not arithmetic_conditions: - # return None + arithmetic_conditions = self.control_behavior.arithmetic_conditions + if not arithmetic_conditions: + return None return self.control_behavior.arithmetic_conditions.output_signal @output_signal.setter def output_signal(self, value: Union[str, SignalID, None]): + if self.control_behavior.arithmetic_conditions is None: + self.control_behavior.arithmetic_conditions = ( + self.Format.ControlBehavior.ArithmeticConditions() + ) + if self.validate_assignment: result = attempt_and_reissue( self, @@ -460,7 +478,6 @@ def set_arithmetic_conditions( second_operand: Union[str, SignalID, int32, None] = 0, output_signal: Union[str, SignalID, None] = None, ): - # type: (Union[str, int], str, Union[str, int], str) -> None """ Sets the entire arithmetic condition of the ``ArithmeticCombinator`` all at once. All of the restrictions for each individual attribute apply. diff --git a/draftsman/prototypes/artillery_wagon.py b/draftsman/prototypes/artillery_wagon.py index ef82398..422bc98 100644 --- a/draftsman/prototypes/artillery_wagon.py +++ b/draftsman/prototypes/artillery_wagon.py @@ -53,8 +53,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # TODO: read the gun prototype for this entity and use that to determine the # kinds of ammo it uses diff --git a/draftsman/prototypes/assembling_machine.py b/draftsman/prototypes/assembling_machine.py index 56b1b73..67ebd0f 100644 --- a/draftsman/prototypes/assembling_machine.py +++ b/draftsman/prototypes/assembling_machine.py @@ -52,6 +52,8 @@ def ensure_module_permitted_with_recipe( ): if not info.context or value is None: return value + if info.context["mode"] <= ValidationMode.MINIMUM: + return value entity: "AssemblingMachine" = info.context["object"] warning_list: list = info.context["warning_list"] @@ -59,24 +61,24 @@ def ensure_module_permitted_with_recipe( if entity.recipe is None: # Cannot check in this case return value - for item in entity.module_items: + for item in entity.items: # Check to make sure the recipe is within the module's limitations # (If it has any) - module = modules.raw[item] + module = modules.raw.get(item, {}) if "limitation" in module: - if ( + if ( # pragma: no branch entity.recipe is not None and entity.recipe not in module["limitation"] ): tooltip = module.get("limitation_message_key", "no message key") - issue = ModuleLimitationWarning( - "Cannot use module '{}' with recipe '{}' ({})".format( - item, entity.recipe, tooltip - ), + warning_list.append( + ModuleLimitationWarning( + "Cannot use module '{}' with recipe '{}' ({})".format( + item, entity.recipe, tooltip + ), + ) ) - warning_list.append(issue) - return value model_config = ConfigDict(title="AssemblingMachine") @@ -116,8 +118,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # @utils.reissue_warnings # def set_item_request(self, item: str, count: uint32): diff --git a/draftsman/prototypes/beacon.py b/draftsman/prototypes/beacon.py index 1e9e549..08544e4 100644 --- a/draftsman/prototypes/beacon.py +++ b/draftsman/prototypes/beacon.py @@ -60,8 +60,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/boiler.py b/draftsman/prototypes/boiler.py index b79d14a..8e91b1a 100644 --- a/draftsman/prototypes/boiler.py +++ b/draftsman/prototypes/boiler.py @@ -55,8 +55,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # TODO: ensure fuel requests to this entity match it's allowed fuel categories diff --git a/draftsman/prototypes/burner_generator.py b/draftsman/prototypes/burner_generator.py index 3c6b4f1..b1a0707 100644 --- a/draftsman/prototypes/burner_generator.py +++ b/draftsman/prototypes/burner_generator.py @@ -42,7 +42,7 @@ def only_allow_fuel_item_requests( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "BurnerGenerator" = info.context["object"] @@ -53,12 +53,13 @@ def only_allow_fuel_item_requests( for item in entity.items: if item in items.raw and item not in items.all_fuel_items: - issue = ItemLimitationWarning( - "Cannot add item '{}' to '{}'; this entity only can only recieve fuel items".format( - item, entity.name + warning_list.append( + ItemLimitationWarning( + "Cannot add item '{}' to '{}'; this entity only can only recieve fuel items".format( + item, entity.name + ) ) ) - warning_list.append(issue) return value @@ -97,8 +98,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/cargo_wagon.py b/draftsman/prototypes/cargo_wagon.py index 13828c5..0a924e0 100644 --- a/draftsman/prototypes/cargo_wagon.py +++ b/draftsman/prototypes/cargo_wagon.py @@ -8,14 +8,12 @@ ) from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import Orientation, ValidationMode -from draftsman.signatures import InventoryFilters, uint32 -from draftsman.warning import DraftsmanWarning +from draftsman.signatures import uint32 from draftsman.data.entities import cargo_wagons -from draftsman.data import entities from pydantic import ConfigDict -from typing import Any, Literal, Optional, Union +from typing import Any, Literal, Union class CargoWagon(RequestItemsMixin, InventoryFilterMixin, OrientationMixin, Entity): @@ -38,7 +36,7 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), orientation: Orientation = Orientation.NORTH, items: dict[str, uint32] = {}, # TODO: ItemID - inventory: InventoryFilters = InventoryFilters(), + inventory: Format.InventoryFilters = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -62,8 +60,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # TODO: check for item requests exceeding cargo capacity diff --git a/draftsman/prototypes/constant_combinator.py b/draftsman/prototypes/constant_combinator.py index 6ab73d4..67c4313 100644 --- a/draftsman/prototypes/constant_combinator.py +++ b/draftsman/prototypes/constant_combinator.py @@ -81,8 +81,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -113,8 +113,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -224,28 +223,35 @@ def set_signal( """ try: - new_entry = SignalFilter(index=index + 1, signal=signal, count=count) + new_entry = SignalFilter(index=index, signal=signal, count=count) + new_entry.index += 1 except ValidationError as e: raise DataFormatError(e) from None - if self.control_behavior.filters is None: - self.control_behavior.filters = [] + new_filters = [] if self.signals is None else self.signals # Check to see if filters already contains an entry with the same index existing_index = None - for i, signal_filter in enumerate(self.control_behavior.filters): + for i, signal_filter in enumerate(new_filters): if index + 1 == signal_filter["index"]: # Index already exists in the list if signal is None: # Delete the entry - del self.control_behavior["filters"][i] - return + del new_filters[i] else: - existing_index = i - break - - if existing_index is not None: - self.control_behavior.filters[existing_index] = new_entry - else: - self.control_behavior.filters.append(new_entry) + new_filters[i] = new_entry + existing_index = i + break + + if existing_index is None: + new_filters.append(new_entry) + + result = attempt_and_reissue( + self, + type(self).Format.ControlBehavior, + self.control_behavior, + "filters", + new_filters, + ) + self.control_behavior.filters = result def get_signal(self, index: int64) -> Optional[SignalFilter]: """ diff --git a/draftsman/prototypes/container.py b/draftsman/prototypes/container.py index 29a97a3..36d5268 100644 --- a/draftsman/prototypes/container.py +++ b/draftsman/prototypes/container.py @@ -36,7 +36,7 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), + connections: Connections = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -64,8 +64,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/curved_rail.py b/draftsman/prototypes/curved_rail.py index 3afe9aa..208d028 100644 --- a/draftsman/prototypes/curved_rail.py +++ b/draftsman/prototypes/curved_rail.py @@ -83,8 +83,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/decider_combinator.py b/draftsman/prototypes/decider_combinator.py index 630de36..bfae7eb 100644 --- a/draftsman/prototypes/decider_combinator.py +++ b/draftsman/prototypes/decider_combinator.py @@ -79,6 +79,8 @@ def ensure_proper_signal_configuration(self, info: ValidationInfo): """ if not info.context or self.output_signal is None: return self + if info.context["mode"] <= ValidationMode.MINIMUM: + return self warning_list: list = info.context["warning_list"] @@ -91,12 +93,13 @@ def ensure_proper_signal_configuration(self, info: ValidationInfo): first_signal_name, {"signal-anything", "signal-each"} ) if self.output_signal.name in current_blacklist: - issue = PureVirtualDisallowedWarning( - "'{}' cannot be an output_signal when '{}' is the first operand; 'output_signal' will be removed when imported".format( - self.output_signal.name, first_signal_name - ), + warning_list.append( + PureVirtualDisallowedWarning( + "'{}' cannot be an output_signal when '{}' is the first operand; 'output_signal' will be removed when imported".format( + self.output_signal.name, first_signal_name + ), + ) ) - warning_list.append(issue) return self @@ -106,16 +109,19 @@ def ensure_second_signal_is_not_pure_virtual( ): if not info.context or self.second_signal is None: return self + if info.context["mode"] <= ValidationMode.MINIMUM: + return self warning_list: list = info.context["warning_list"] if self.second_signal.name in signals.pure_virtual: - issue = PureVirtualDisallowedWarning( - "'second_signal' cannot be set to pure virtual signal '{}'; will be removed when imported".format( - self.second_signal.name + warning_list.append( + PureVirtualDisallowedWarning( + "'second_signal' cannot be set to pure virtual signal '{}'; will be removed when imported".format( + self.second_signal.name + ) ) ) - warning_list.append(issue) return self @@ -131,8 +137,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -162,8 +168,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -320,7 +325,7 @@ def second_operand( if value is None: # Default self.control_behavior.decider_conditions.second_signal = None self.control_behavior.decider_conditions.constant = None - elif isinstance(value, int): # Constant + elif isinstance(value, (int, float)): # Constant if self.validate_assignment: value = attempt_and_reissue( self, @@ -347,7 +352,6 @@ def second_operand( @property def output_signal(self) -> Optional[SignalID]: - # type: () -> dict """ The output signal of the ``ArithmeticCombinator``. @@ -445,7 +449,7 @@ def set_decider_conditions( } } - if isinstance(second_operand, int): + if isinstance(second_operand, (int, float)): new_control_behavior["decider_conditions"]["constant"] = second_operand else: new_control_behavior["decider_conditions"]["second_signal"] = second_operand diff --git a/draftsman/prototypes/electric_energy_interface.py b/draftsman/prototypes/electric_energy_interface.py index bc96e15..7d902da 100644 --- a/draftsman/prototypes/electric_energy_interface.py +++ b/draftsman/prototypes/electric_energy_interface.py @@ -99,8 +99,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -163,7 +162,6 @@ def buffer_size(self) -> Optional[float]: @buffer_size.setter def buffer_size(self, value: Optional[float]): - # type: (int) -> None if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "buffer_size", value diff --git a/draftsman/prototypes/electric_pole.py b/draftsman/prototypes/electric_pole.py index c2fff46..ec88182 100644 --- a/draftsman/prototypes/electric_pole.py +++ b/draftsman/prototypes/electric_pole.py @@ -32,7 +32,7 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), neighbours: list[uint64] = [], - connections: Connections = Connections(), + connections: Connections = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -59,8 +59,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/filter_inserter.py b/draftsman/prototypes/filter_inserter.py index ac3c792..ca5ceaa 100644 --- a/draftsman/prototypes/filter_inserter.py +++ b/draftsman/prototypes/filter_inserter.py @@ -19,11 +19,11 @@ ) from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import Direction, ValidationMode -from draftsman.signatures import Connections, DraftsmanBaseModel, Filters, uint8 +from draftsman.signatures import Connections, DraftsmanBaseModel, FilterEntry, uint8 from draftsman.data.entities import filter_inserters -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, field_validator from typing import Any, Literal, Optional, Union @@ -86,6 +86,22 @@ class ControlBehavior( """, ) + # @field_validator("filters", mode="before") + # @classmethod + # def handle_filter_shorthand(cls, value: Any): + # print("input:", value) + # if isinstance(value, (list, tuple)): + # result = [] + # for i, entry in enumerate(value): + # if isinstance(entry, str): + # result.append({"index": i + 1, "name": entry}) + # else: + # result.append(entry) + # print("result:", result) + # return result + # else: + # return value + model_config = ConfigDict(title="FilterInserter") def __init__( @@ -95,10 +111,10 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, override_stack_size: uint8 = None, - filters: Filters = Filters(), + filters: list[FilterEntry] = [], filter_mode: Literal["whitelist", "blacklist"] = "whitelist", - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -132,8 +148,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/fluid_wagon.py b/draftsman/prototypes/fluid_wagon.py index d1537c3..df3f62b 100644 --- a/draftsman/prototypes/fluid_wagon.py +++ b/draftsman/prototypes/fluid_wagon.py @@ -55,8 +55,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/furnace.py b/draftsman/prototypes/furnace.py index a7cc3ef..f1b7cac 100644 --- a/draftsman/prototypes/furnace.py +++ b/draftsman/prototypes/furnace.py @@ -52,7 +52,7 @@ def ensure_input_ingredients_are_valid( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "Furnace" = info.context["object"] @@ -63,14 +63,14 @@ def ensure_input_ingredients_are_valid( item not in modules.raw and item not in entity.allowed_input_ingredients ): - issue = ItemLimitationWarning( - "Requested item '{}' cannot be smelted by furnace '{}'".format( - item, entity.name - ), + warning_list.append( + ItemLimitationWarning( + "Requested item '{}' cannot be smelted by furnace '{}'".format( + item, entity.name + ), + ) ) - warning_list.append(issue) - return value @field_validator("items") @@ -85,7 +85,7 @@ def ensure_input_ingredients_dont_exceed_stack_size( """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "Furnace" = info.context["object"] @@ -95,14 +95,14 @@ def ensure_input_ingredients_dont_exceed_stack_size( print(item, count) stack_size = items.raw[item]["stack_size"] if count > stack_size: - issue = ItemCapacityWarning( - "Cannot request more than {} of '{}' to a '{}'; will not fit in ingredient inputs".format( - stack_size, item, entity.name + warning_list.append( + ItemCapacityWarning( + "Cannot request more than {} of '{}' to a '{}'; will not fit in ingredient inputs".format( + stack_size, item, entity.name + ) ) ) - warning_list.append(issue) - return value model_config = ConfigDict(title="Furnace") @@ -156,8 +156,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/gate.py b/draftsman/prototypes/gate.py index e8902d4..9c6f252 100644 --- a/draftsman/prototypes/gate.py +++ b/draftsman/prototypes/gate.py @@ -50,8 +50,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/generator.py b/draftsman/prototypes/generator.py index 494f18a..81b982c 100644 --- a/draftsman/prototypes/generator.py +++ b/draftsman/prototypes/generator.py @@ -50,8 +50,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/heat_interface.py b/draftsman/prototypes/heat_interface.py index f9bfe31..8b065ab 100644 --- a/draftsman/prototypes/heat_interface.py +++ b/draftsman/prototypes/heat_interface.py @@ -39,21 +39,20 @@ class Format(Entity.Format): def clamp_temperature(cls, value: Optional[float], info: ValidationInfo): if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.STRICT: return value warning_list: list = info.context["warning_list"] if not 0.0 <= value <= 1000.0: - issue = TemperatureRangeWarning( - "Temperature '{}' exceeds allowed range [0.0, 1000.0]; will be clamped to this range on import" + warning_list.append( + TemperatureRangeWarning( + "Temperature '{}' exceeds allowed range [0.0, 1000.0]; will be clamped to this range on import".format( + value + ) + ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return value model_config = ConfigDict(title="HeatInterface") @@ -93,8 +92,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/heat_pipe.py b/draftsman/prototypes/heat_pipe.py index a7ee99a..c2d615f 100644 --- a/draftsman/prototypes/heat_pipe.py +++ b/draftsman/prototypes/heat_pipe.py @@ -47,8 +47,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/infinity_container.py b/draftsman/prototypes/infinity_container.py index 358e97b..d7b4d13 100644 --- a/draftsman/prototypes/infinity_container.py +++ b/draftsman/prototypes/infinity_container.py @@ -80,7 +80,7 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, items: dict[str, uint32] = {}, # TODO: ItemID - infinity_settings: Format.InfinitySettings = Format.InfinitySettings(), + infinity_settings: Format.InfinitySettings = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -111,8 +111,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/infinity_pipe.py b/draftsman/prototypes/infinity_pipe.py index 035d383..681d8f3 100644 --- a/draftsman/prototypes/infinity_pipe.py +++ b/draftsman/prototypes/infinity_pipe.py @@ -4,10 +4,10 @@ from draftsman.classes.exportable import attempt_and_reissue from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import ValidationMode -from draftsman.signatures import DraftsmanBaseModel, int64 +from draftsman.signatures import DraftsmanBaseModel, int64, FluidName from draftsman.data.entities import infinity_pipes -from draftsman.data import signals, fluids +from draftsman.data import fluids import copy from pydantic import ConfigDict, Field, model_validator @@ -21,7 +21,7 @@ class InfinityPipe(Entity): class Format(Entity.Format): class InfinitySettings(DraftsmanBaseModel): - name: Optional[str] = Field( # TODO: FluidID + name: Optional[FluidName] = Field( None, description=""" The fluid to infinitely generate or consume. @@ -44,7 +44,7 @@ class InfinitySettings(DraftsmanBaseModel): What action to perform when connected to a fluid network. """, ) - temperature: Optional[int64] = Field( # TODO dimension + temperature: Optional[int64] = Field( None, description=""" The temperature with which to keep the fluid at, in degrees @@ -71,9 +71,7 @@ def validate_temperature_range(self): raise ValueError( "Infinite fluid temperature cannot be set without infinite fluid name" ) - elif ( - self.name is not None and self.name in fluids.raw - ): # TODO: fluids.raw? + elif self.name is not None and self.name in fluids.raw: min_temp, max_temp = fluids.get_temperature_range(self.name) if not min_temp <= self.temperature <= max_temp: raise ValueError( @@ -93,7 +91,7 @@ def __init__( name: str = infinity_pipes[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - infinity_settings: Format.InfinitySettings = Format.InfinitySettings(), + infinity_settings: Format.InfinitySettings = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -122,8 +120,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -156,7 +153,7 @@ def infinity_settings(self, value: Optional[Format.InfinitySettings]): # ========================================================================= @property - def infinite_fluid_name(self) -> str: # TODO: FluidID + def infinite_fluid_name(self) -> Optional[str]: """ Sets the name of the infinite fluid. @@ -170,7 +167,7 @@ def infinite_fluid_name(self) -> str: # TODO: FluidID return self.infinity_settings.get("name", None) @infinite_fluid_name.setter - def infinite_fluid_name(self, value): + def infinite_fluid_name(self, value: Optional[str]): print("name") if self.validate_assignment: result = attempt_and_reissue( @@ -187,7 +184,7 @@ def infinite_fluid_name(self, value): # ========================================================================= @property - def infinite_fluid_percentage(self) -> float: + def infinite_fluid_percentage(self) -> Optional[float]: """ The percentage of the infinite fluid in the pipe, where ``1.0`` is 100%. @@ -201,7 +198,7 @@ def infinite_fluid_percentage(self) -> float: return self.infinity_settings.get("percentage", None) @infinite_fluid_percentage.setter - def infinite_fluid_percentage(self, value): + def infinite_fluid_percentage(self, value: Optional[float]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -260,7 +257,7 @@ def infinite_fluid_mode( # ========================================================================= @property - def infinite_fluid_temperature(self) -> int64: + def infinite_fluid_temperature(self) -> Optional[int64]: """ The temperature of the infinite fluid, in degrees. @@ -276,7 +273,7 @@ def infinite_fluid_temperature(self) -> int64: return self.infinity_settings.get("temperature", None) @infinite_fluid_temperature.setter - def infinite_fluid_temperature(self, value: int64): + def infinite_fluid_temperature(self, value: Optional[int64]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -293,7 +290,7 @@ def infinite_fluid_temperature(self, value: int64): def set_infinite_fluid( self, - name: str = None, # TODO: FluidID + name: str = None, percentage: float = 0.0, mode: Literal[ "at-least", "at-most", "exactly", "add", "remove", None diff --git a/draftsman/prototypes/inserter.py b/draftsman/prototypes/inserter.py index 79c6bdb..beaf7d8 100644 --- a/draftsman/prototypes/inserter.py +++ b/draftsman/prototypes/inserter.py @@ -81,8 +81,8 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, override_stack_size: uint8 = None, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -107,8 +107,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/lab.py b/draftsman/prototypes/lab.py index 4f188f1..cd9bd58 100644 --- a/draftsman/prototypes/lab.py +++ b/draftsman/prototypes/lab.py @@ -6,7 +6,7 @@ from draftsman.constants import ValidationMode from draftsman.signatures import uint32 from draftsman.utils import reissue_warnings -from draftsman.warning import DraftsmanWarning, ItemLimitationWarning +from draftsman.warning import ItemLimitationWarning from draftsman.data.entities import labs from draftsman.data import entities, modules @@ -59,8 +59,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/lamp.py b/draftsman/prototypes/lamp.py index 83cb81e..603a187 100644 --- a/draftsman/prototypes/lamp.py +++ b/draftsman/prototypes/lamp.py @@ -50,8 +50,8 @@ def __init__( name: str = lamps[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -81,8 +81,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/land_mine.py b/draftsman/prototypes/land_mine.py index 3fe552f..815c70d 100644 --- a/draftsman/prototypes/land_mine.py +++ b/draftsman/prototypes/land_mine.py @@ -47,8 +47,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/linked_belt.py b/draftsman/prototypes/linked_belt.py index 8da3923..66ae678 100644 --- a/draftsman/prototypes/linked_belt.py +++ b/draftsman/prototypes/linked_belt.py @@ -68,8 +68,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/linked_container.py b/draftsman/prototypes/linked_container.py index fbe1d46..1666158 100644 --- a/draftsman/prototypes/linked_container.py +++ b/draftsman/prototypes/linked_container.py @@ -83,13 +83,12 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @property - def link_id(self) -> uint32: + def link_id(self) -> Optional[uint32]: """ The linking ID that this ``LinkedContainer`` currently has. Encoded as a 32 bit unsigned integer, where a container only links to another with @@ -105,8 +104,7 @@ def link_id(self) -> uint32: return self._root.link_id @link_id.setter - def link_id(self, value): - # type: (int) -> None + def link_id(self, value: Optional[uint32]): if self.validate_assignment: value = attempt_and_reissue( self, type(self).Format, self._root, "link_id", value @@ -119,8 +117,7 @@ def link_id(self, value): # ========================================================================= - def set_link(self, number, enabled): - # type: (int, bool) -> None + def set_link(self, number: int, enabled: bool): """ Set a single "link point". Corresponds to flipping a single bit in ``link_id``. @@ -141,8 +138,7 @@ def set_link(self, number, enabled): else: self.link_id &= ~(1 << number) - def merge(self, other): - # type: (LinkedContainer) -> None + def merge(self, other: "LinkedContainer"): super(LinkedContainer, self).merge(other) self.link_id = other.link_id @@ -151,5 +147,5 @@ def merge(self, other): __hash__ = Entity.__hash__ - def __eq__(self, other) -> bool: + def __eq__(self, other: "LinkedContainer") -> bool: return super().__eq__(other) and self.link_id == other.link_id diff --git a/draftsman/prototypes/loader.py b/draftsman/prototypes/loader.py index cec14b5..c999aa9 100644 --- a/draftsman/prototypes/loader.py +++ b/draftsman/prototypes/loader.py @@ -4,7 +4,7 @@ from draftsman.classes.mixins import FiltersMixin, IOTypeMixin, DirectionalMixin from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import Direction, ValidationMode -from draftsman.signatures import Filters +from draftsman.signatures import FilterEntry from draftsman.data.entities import loaders @@ -33,7 +33,7 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, io_type: Literal["input", "output"] = "input", - filters: Filters = Filters([]), + filters: list[FilterEntry] = [], tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -57,8 +57,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/locomotive.py b/draftsman/prototypes/locomotive.py index 114a217..a3fa45e 100644 --- a/draftsman/prototypes/locomotive.py +++ b/draftsman/prototypes/locomotive.py @@ -54,8 +54,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # TODO: check if item requests are valid fuel sources or not diff --git a/draftsman/prototypes/logistic_active_container.py b/draftsman/prototypes/logistic_active_container.py index cdcfb41..cc75cc4 100644 --- a/draftsman/prototypes/logistic_active_container.py +++ b/draftsman/prototypes/logistic_active_container.py @@ -43,7 +43,7 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), + connections: Connections = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -67,8 +67,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/logistic_buffer_container.py b/draftsman/prototypes/logistic_buffer_container.py index f28d19f..b33e402 100644 --- a/draftsman/prototypes/logistic_buffer_container.py +++ b/draftsman/prototypes/logistic_buffer_container.py @@ -18,7 +18,7 @@ from draftsman.signatures import ( Connections, DraftsmanBaseModel, - RequestFilters, + RequestFilter, uint16, uint32, ) @@ -64,10 +64,10 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, - request_filters: RequestFilters = RequestFilters([]), + request_filters: list[RequestFilter] = [], items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -97,8 +97,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/logistic_passive_container.py b/draftsman/prototypes/logistic_passive_container.py index b3f3119..45b97be 100644 --- a/draftsman/prototypes/logistic_passive_container.py +++ b/draftsman/prototypes/logistic_passive_container.py @@ -40,7 +40,7 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), + connections: Connections = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -64,8 +64,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/logistic_request_container.py b/draftsman/prototypes/logistic_request_container.py index 33bd14b..12a2bcb 100644 --- a/draftsman/prototypes/logistic_request_container.py +++ b/draftsman/prototypes/logistic_request_container.py @@ -18,7 +18,7 @@ from draftsman.signatures import ( Connections, DraftsmanBaseModel, - RequestFilters, + RequestFilter, uint16, uint32, ) @@ -51,7 +51,9 @@ class Format( RequestFiltersMixin.Format, Entity.Format, ): - class ControlBehavior(LogisticModeOfOperationMixin.Format, DraftsmanBaseModel): + class ControlBehavior( + LogisticModeOfOperationMixin.ControlFormat, DraftsmanBaseModel + ): pass control_behavior: Optional[ControlBehavior] = ControlBehavior() @@ -71,10 +73,10 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, - request_filters: RequestFilters = RequestFilters([]), + request_filters: list[RequestFilter] = [], items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, request_from_buffers: bool = False, tags: dict[str, Any] = {}, validate: Union[ @@ -105,8 +107,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/logistic_storage_container.py b/draftsman/prototypes/logistic_storage_container.py index e5b7232..8617e6e 100644 --- a/draftsman/prototypes/logistic_storage_container.py +++ b/draftsman/prototypes/logistic_storage_container.py @@ -9,7 +9,7 @@ ) from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import ValidationMode -from draftsman.signatures import Connections, RequestFilters, uint16, uint32 +from draftsman.signatures import Connections, RequestFilter, uint16, uint32 from draftsman.data.entities import logistic_storage_containers @@ -44,9 +44,9 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), bar: uint16 = None, - request_filters: RequestFilters = RequestFilters([]), + request_filters: list[RequestFilter] = [], items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), + connections: Connections = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -75,8 +75,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/mining_drill.py b/draftsman/prototypes/mining_drill.py index cfe84fc..e45315c 100644 --- a/draftsman/prototypes/mining_drill.py +++ b/draftsman/prototypes/mining_drill.py @@ -78,8 +78,8 @@ def __init__( tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, items: dict[str, uint32] = {}, # TODO: ItemID - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -108,8 +108,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/offshore_pump.py b/draftsman/prototypes/offshore_pump.py index d3bf4f6..184d817 100644 --- a/draftsman/prototypes/offshore_pump.py +++ b/draftsman/prototypes/offshore_pump.py @@ -55,8 +55,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -69,6 +69,8 @@ def __init__( """ TODO """ + self._root: __class__.Format + self.control_behavior: __class__.Format.ControlBehavior super().__init__( name, @@ -84,8 +86,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/pipe.py b/draftsman/prototypes/pipe.py index 1692a10..bf116dc 100644 --- a/draftsman/prototypes/pipe.py +++ b/draftsman/prototypes/pipe.py @@ -47,8 +47,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/player_port.py b/draftsman/prototypes/player_port.py index a8162d7..6e8ab10 100644 --- a/draftsman/prototypes/player_port.py +++ b/draftsman/prototypes/player_port.py @@ -47,5 +47,4 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) diff --git a/draftsman/prototypes/power_switch.py b/draftsman/prototypes/power_switch.py index ac253f3..f0bc893 100644 --- a/draftsman/prototypes/power_switch.py +++ b/draftsman/prototypes/power_switch.py @@ -13,9 +13,7 @@ from draftsman.classes.vector import Vector, PrimitiveVector from draftsman.constants import ValidationMode from draftsman.data import entities -from draftsman.error import DataFormatError from draftsman.signatures import Connections, DraftsmanBaseModel -from draftsman.warning import DraftsmanWarning from draftsman.data.entities import power_switches @@ -70,8 +68,8 @@ def __init__( name=power_switches[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, switch_state: bool = False, tags: dict[str, Any] = {}, validate: Union[ @@ -97,8 +95,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/programmable_speaker.py b/draftsman/prototypes/programmable_speaker.py index 4a341b8..4d1d3d0 100644 --- a/draftsman/prototypes/programmable_speaker.py +++ b/draftsman/prototypes/programmable_speaker.py @@ -21,7 +21,13 @@ import draftsman.data.instruments as instruments_data from draftsman.data.signals import signal_dict -from pydantic import ConfigDict, Field, ValidationInfo, field_validator +from pydantic import ( + ConfigDict, + Field, + ValidatorFunctionWrapHandler, + ValidationInfo, + field_validator, +) import six from typing import Any, Literal, Optional, Union import warnings @@ -41,11 +47,16 @@ class ProgrammableSpeaker( class Format( CircuitConditionMixin.Format, + EnableDisableMixin.Format, ControlBehaviorMixin.Format, CircuitConnectableMixin.Format, Entity.Format, ): - class ControlBehavior(CircuitConditionMixin.ControlFormat, DraftsmanBaseModel): + class ControlBehavior( + CircuitConditionMixin.ControlFormat, + EnableDisableMixin.ControlFormat, + DraftsmanBaseModel, + ): class CircuitParameters(DraftsmanBaseModel): signal_value_is_pitch: Optional[bool] = Field( False, @@ -94,7 +105,7 @@ def ensure_instrument_id_known( ): if not info.context: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "ProgrammableSpeaker" = info.context["object"] @@ -102,24 +113,21 @@ def ensure_instrument_id_known( # If we don't recognize entity.name, then we can't know that # the ID is invalid - if entity.name not in instruments_data.names: + if entity.name not in instruments_data.name_of: return value if ( value is not None - and value not in instruments_data.names[entity.name] + and value not in instruments_data.name_of[entity.name] ): - issue = UnknownInstrumentWarning( - "ID '{}' is not a known instrument for this programmable speaker".format( - value + warning_list.append( + UnknownInstrumentWarning( + "ID '{}' is not a known instrument for this programmable speaker".format( + value + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return value @field_validator("instrument_name") @@ -129,7 +137,7 @@ def ensure_instrument_name_known( ): if not info.context: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "ProgrammableSpeaker" = info.context["object"] @@ -137,24 +145,21 @@ def ensure_instrument_name_known( # If we don't recognize entity.name, then we can't know that # the ID is invalid - if entity.name not in instruments_data.index: + if entity.name not in instruments_data.index_of: return value if ( value is not None - and value not in instruments_data.index[entity.name] + and value not in instruments_data.index_of[entity.name] ): - issue = UnknownInstrumentWarning( - "Name '{}' is not a known instrument for this programmable speaker".format( - value + warning_list.append( + UnknownInstrumentWarning( + "Name '{}' is not a known instrument for this programmable speaker".format( + value + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return value @field_validator("note_id") @@ -164,7 +169,7 @@ def ensure_note_id_known( ): if not info.context: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "ProgrammableSpeaker" = info.context["object"] @@ -172,27 +177,29 @@ def ensure_note_id_known( # If we don't recognize entity.name or instrument_id, then # we can't know that the ID is invalid - if entity.name not in instruments_data.names: + if entity.name not in instruments_data.name_of: return value - if entity.instrument_id not in instruments_data.names[entity.name]: + if ( + entity.instrument_id + not in instruments_data.name_of[entity.name] + ): return value if ( value is not None and value - not in instruments_data.names[entity.name][entity.instrument_id] + not in instruments_data.name_of[entity.name][ + entity.instrument_id + ] ): - issue = UnknownNoteWarning( - "ID '{}' is not a known note for this instrument and/or programmable speaker".format( - value + warning_list.append( + UnknownNoteWarning( + "ID '{}' is not a known note for this instrument and/or programmable speaker".format( + value + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return value @field_validator("note_name") @@ -202,7 +209,7 @@ def ensure_note_name_known( ): if not info.context: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value entity: "ProgrammableSpeaker" = info.context["object"] @@ -210,32 +217,29 @@ def ensure_note_name_known( # If we don't recognize entity.name or instrument_id, then # we can't know that the ID is invalid - if entity.name not in instruments_data.index: + if entity.name not in instruments_data.index_of: return value if ( entity.instrument_name - not in instruments_data.index[entity.name] + not in instruments_data.index_of[entity.name] ): return value if ( value is not None and value - not in instruments_data.index[entity.name][ + not in instruments_data.index_of[entity.name][ entity.instrument_name ] ): - issue = UnknownNoteWarning( - "Name '{}' is not a known note for this instrument and/or programmable speaker".format( - value + warning_list.append( + UnknownNoteWarning( + "Name '{}' is not a known note for this instrument and/or programmable speaker".format( + value + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return value circuit_parameters: Optional[CircuitParameters] = CircuitParameters() @@ -334,10 +338,10 @@ def __init__( name=programmable_speakers[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), - parameters: Format.Parameters = Format.Parameters(), - alert_parameters: Format.AlertParameters = Format.AlertParameters(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, + parameters: Format.Parameters = {}, + alert_parameters: Format.AlertParameters = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -378,8 +382,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -737,7 +740,7 @@ def instrument_name(self) -> Optional[str]: :exception TypeError: If set to anything other than a ``str`` or ``None``. """ return ( - instruments_data.names.get(self.name, {}) + instruments_data.name_of.get(self.name, {}) .get(self.instrument_id, {}) .get("self", None) ) @@ -757,7 +760,7 @@ def instrument_name(self, value: Optional[str]): self.control_behavior.circuit_parameters.instrument_id = None else: new_id = ( - instruments_data.index.get(self.name, {}) + instruments_data.index_of.get(self.name, {}) .get(value, {}) .get("self", None) ) @@ -810,16 +813,8 @@ def note_name(self): as a valid instrument name for this speaker. :exception TypeError: If set to anything other than a ``str`` or ``None``. """ - # entity = instruments_data.names.get(self.name, {}) - # print("start") - # print(entity) - # print(self.instrument_id) - # instrument = entity.get(self.instrument_id, {}) - # print(instrument) - # print(self.note_id) - # return instrument.get(self.note_id, None) return ( - instruments_data.names.get(self.name, {}) + instruments_data.name_of.get(self.name, {}) .get(self.instrument_id, {}) .get(self.note_id, None) ) @@ -843,7 +838,7 @@ def note_name(self, value: Optional[str]): self.control_behavior.circuit_parameters.note_id = None else: new_id = ( - instruments_data.index.get(self.name, {}) + instruments_data.index_of.get(self.name, {}) .get(self.instrument_name, {}) .get(value, None) ) diff --git a/draftsman/prototypes/pump.py b/draftsman/prototypes/pump.py index 057b99b..f057e5e 100644 --- a/draftsman/prototypes/pump.py +++ b/draftsman/prototypes/pump.py @@ -69,8 +69,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/radar.py b/draftsman/prototypes/radar.py index 3047e09..b26b7cd 100644 --- a/draftsman/prototypes/radar.py +++ b/draftsman/prototypes/radar.py @@ -42,8 +42,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/rail_chain_signal.py b/draftsman/prototypes/rail_chain_signal.py index 846f4ed..db642d9 100644 --- a/draftsman/prototypes/rail_chain_signal.py +++ b/draftsman/prototypes/rail_chain_signal.py @@ -55,8 +55,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections() = Connections(), - control_behavior: Format.ControlBehavior() = Format.ControlBehavior(), + connections: Connections() = {}, + control_behavior: Format.ControlBehavior() = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -91,8 +91,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/rail_signal.py b/draftsman/prototypes/rail_signal.py index 5511584..fc6afc9 100644 --- a/draftsman/prototypes/rail_signal.py +++ b/draftsman/prototypes/rail_signal.py @@ -46,7 +46,7 @@ class Format( class ControlBehavior( ReadRailSignalMixin.ControlFormat, CircuitConditionMixin.ControlFormat, - DraftsmanBaseModel, # TODO: FIXME? + DraftsmanBaseModel, ): circuit_close_signal: Optional[bool] = Field( @@ -74,8 +74,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections() = Connections(), - control_behavior: Format.ControlBehavior() = Format.ControlBehavior(), + connections: Connections() = {}, + control_behavior: Format.ControlBehavior() = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -110,8 +110,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/reactor.py b/draftsman/prototypes/reactor.py index 6860217..6931395 100644 --- a/draftsman/prototypes/reactor.py +++ b/draftsman/prototypes/reactor.py @@ -8,8 +8,8 @@ from draftsman.data.entities import reactors -from pydantic import ConfigDict -from typing import Any, Literal, Union +from pydantic import ConfigDict, ValidationInfo, field_validator +from typing import Any, Literal, Optional, Union class Reactor(BurnerEnergySourceMixin, RequestItemsMixin, Entity): @@ -27,7 +27,7 @@ def __init__( name: str = reactors[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - items: dict[str, uint32] = {}, # TODO: ItemID + items: dict[str, uint32] = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -53,13 +53,18 @@ def __init__( # TODO: technically, a reactor can have more than just a burnable energy # source; it could have any type of energy source other than heat as - # input. Thus, we would only want to inhert the attributes of - # `BurnerEnergySourceMixin` if that is the case, which is tricky + # input. Thus, we need to make sure that the attributes from + # BurnerEnergySourceMixin are only used in the correct configuration self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) + + # ========================================================================= + + @property + def allowed_items(self) -> Optional[set[str]]: + return self.allowed_fuel_items # ========================================================================= diff --git a/draftsman/prototypes/roboport.py b/draftsman/prototypes/roboport.py index 81bb09e..378b884 100644 --- a/draftsman/prototypes/roboport.py +++ b/draftsman/prototypes/roboport.py @@ -10,7 +10,6 @@ from draftsman.signatures import Connections, DraftsmanBaseModel, SignalID from draftsman.data.entities import roboports -from draftsman.data.signals import signal_dict from pydantic import ConfigDict, Field from typing import Any, Literal, Optional, Union @@ -76,8 +75,8 @@ def __init__( name: str = roboports[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior() = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior() = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -106,8 +105,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -192,9 +190,7 @@ def available_logistic_signal(self) -> Optional[SignalID]: return self.control_behavior.available_logistic_output_signal @available_logistic_signal.setter - def available_logistic_signal( - self, value: Union[str, SignalID, None] - ): # TODO: SignalStr + def available_logistic_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -226,9 +222,7 @@ def total_logistic_signal(self) -> Optional[SignalID]: return self.control_behavior.total_logistic_output_signal @total_logistic_signal.setter - def total_logistic_signal( - self, value: Union[str, SignalID, None] - ): # TODO: SignalStr + def total_logistic_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -261,9 +255,7 @@ def available_construction_signal(self) -> Optional[SignalID]: return self.control_behavior.available_construction_output_signal @available_construction_signal.setter - def available_construction_signal( - self, value: Union[str, SignalID, None] - ): # TODO: SignalStr + def available_construction_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -296,9 +288,7 @@ def total_construction_signal(self) -> Optional[SignalID]: return self.control_behavior.total_construction_output_signal @total_construction_signal.setter - def total_construction_signal( - self, value: Union[str, SignalID, None] - ): # TODO: SignalStr + def total_construction_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, diff --git a/draftsman/prototypes/rocket_silo.py b/draftsman/prototypes/rocket_silo.py index 1a31eed..c0df172 100644 --- a/draftsman/prototypes/rocket_silo.py +++ b/draftsman/prototypes/rocket_silo.py @@ -65,8 +65,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/simple_entity_with_force.py b/draftsman/prototypes/simple_entity_with_force.py index 3ca65bd..bfea888 100644 --- a/draftsman/prototypes/simple_entity_with_force.py +++ b/draftsman/prototypes/simple_entity_with_force.py @@ -2,8 +2,9 @@ from draftsman.classes.entity import Entity from draftsman.classes.exportable import attempt_and_reissue +from draftsman.classes.mixins import DirectionalMixin from draftsman.classes.vector import Vector, PrimitiveVector -from draftsman.constants import ValidationMode +from draftsman.constants import Direction, ValidationMode from draftsman.signatures import uint16 from draftsman.data.entities import simple_entities_with_force @@ -12,12 +13,12 @@ from typing import Any, Literal, Optional, Union -class SimpleEntityWithForce(Entity): +class SimpleEntityWithForce(DirectionalMixin, Entity): """ A generic entity associated with a team of players. """ - class Format(Entity.Format): + class Format(DirectionalMixin.Format, Entity.Format): variation: Optional[uint16] = Field( 1, # I think this is the default description=""" @@ -34,6 +35,7 @@ def __init__( name: str = simple_entities_with_force[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), + direction: Direction = Direction.NORTH, tags: dict[str, Any] = {}, variation: uint16 = 1, validate: Union[ @@ -55,6 +57,7 @@ def __init__( simple_entities_with_force, position=position, tile_position=tile_position, + direction=direction, tags=tags, **kwargs ) @@ -63,8 +66,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/simple_entity_with_owner.py b/draftsman/prototypes/simple_entity_with_owner.py index a2239f4..5f4dfef 100644 --- a/draftsman/prototypes/simple_entity_with_owner.py +++ b/draftsman/prototypes/simple_entity_with_owner.py @@ -2,8 +2,9 @@ from draftsman.classes.entity import Entity from draftsman.classes.exportable import attempt_and_reissue +from draftsman.classes.mixins import DirectionalMixin from draftsman.classes.vector import Vector, PrimitiveVector -from draftsman.constants import ValidationMode +from draftsman.constants import Direction, ValidationMode from draftsman.signatures import uint16 from draftsman.data.entities import simple_entities_with_owner @@ -12,12 +13,12 @@ from typing import Any, Literal, Optional, Union -class SimpleEntityWithOwner(Entity): +class SimpleEntityWithOwner(DirectionalMixin, Entity): """ A generic entity owned by some other entity. """ - class Format(Entity.Format): + class Format(DirectionalMixin.Format, Entity.Format): variation: Optional[uint16] = Field( 1, # I think this is the default description=""" @@ -34,6 +35,7 @@ def __init__( name: str = simple_entities_with_owner[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), + direction: Direction = Direction.NORTH, tags: dict[str, Any] = {}, variation: uint16 = 1, validate: Union[ @@ -50,11 +52,12 @@ def __init__( self._root: __class__.Format - super(SimpleEntityWithOwner, self).__init__( + super().__init__( name, simple_entities_with_owner, position=position, tile_position=tile_position, + direction=direction, tags=tags, **kwargs ) @@ -63,8 +66,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/solar_panel.py b/draftsman/prototypes/solar_panel.py index 02002f7..74e9199 100644 --- a/draftsman/prototypes/solar_panel.py +++ b/draftsman/prototypes/solar_panel.py @@ -47,8 +47,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/splitter.py b/draftsman/prototypes/splitter.py index c8af02c..2821bee 100644 --- a/draftsman/prototypes/splitter.py +++ b/draftsman/prototypes/splitter.py @@ -91,8 +91,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/storage_tank.py b/draftsman/prototypes/storage_tank.py index ce966cb..9499a0f 100644 --- a/draftsman/prototypes/storage_tank.py +++ b/draftsman/prototypes/storage_tank.py @@ -31,7 +31,7 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), + connections: Connections = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -58,8 +58,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/straight_rail.py b/draftsman/prototypes/straight_rail.py index 203cfec..e578367 100644 --- a/draftsman/prototypes/straight_rail.py +++ b/draftsman/prototypes/straight_rail.py @@ -84,21 +84,20 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @property def double_grid_aligned(self) -> bool: return True - + # ========================================================================= @property def collision_set(self) -> Optional[CollisionSet]: return _collision_set_rotation.get(self.direction, None) - + # ========================================================================= __hash__ = Entity.__hash__ diff --git a/draftsman/prototypes/train_stop.py b/draftsman/prototypes/train_stop.py index 77944dc..b131949 100644 --- a/draftsman/prototypes/train_stop.py +++ b/draftsman/prototypes/train_stop.py @@ -20,7 +20,13 @@ from draftsman.data.entities import train_stops from draftsman.data.signals import signal_dict -from pydantic import ConfigDict, Field +from pydantic import ( + ConfigDict, + Field, + ValidationInfo, + ValidatorFunctionWrapHandler, + field_validator, +) from typing import Any, Literal, Optional, Union @@ -138,8 +144,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -173,8 +179,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= @@ -185,7 +190,7 @@ def double_grid_aligned(self) -> bool: # ========================================================================= @property - def station(self) -> str: + def station(self) -> Optional[str]: """ The name of this station. TODO more @@ -193,7 +198,7 @@ def station(self) -> str: return self._root.station @station.setter - def station(self, value: str): + def station(self, value: Optional[str]): if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "station", value @@ -205,7 +210,7 @@ def station(self, value: str): # ========================================================================= @property - def manual_trains_limit(self) -> uint32: + def manual_trains_limit(self) -> Optional[uint32]: """ A limit to the amount of trains that can use this stop. Overridden by the circuit signal set train limit (if present). @@ -213,7 +218,7 @@ def manual_trains_limit(self) -> uint32: return self._root.manual_trains_limit @manual_trains_limit.setter - def manual_trains_limit(self, value: uint32): + def manual_trains_limit(self, value: Optional[uint32]): if self.validate_assignment: result = attempt_and_reissue( self, type(self).Format, self._root, "manual_trains_limit", value @@ -225,7 +230,7 @@ def manual_trains_limit(self, value: uint32): # ========================================================================= @property - def send_to_train(self) -> bool: + def send_to_train(self) -> Optional[bool]: """ Whether or not to send the contents of any connected circuit network to the train to determine it's schedule. @@ -233,7 +238,7 @@ def send_to_train(self) -> bool: return self.control_behavior.send_to_train @send_to_train.setter - def send_to_train(self, value: bool): + def send_to_train(self, value: Optional[bool]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -249,7 +254,7 @@ def send_to_train(self, value: bool): # ========================================================================= @property - def read_from_train(self) -> bool: + def read_from_train(self) -> Optional[bool]: """ Whether or not to read the train's contents when stopped at this train stop. @@ -257,7 +262,7 @@ def read_from_train(self) -> bool: return self.control_behavior.read_from_train @read_from_train.setter - def read_from_train(self, value: bool): + def read_from_train(self, value: Optional[bool]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -273,7 +278,7 @@ def read_from_train(self, value: bool): # ========================================================================= @property - def read_stopped_train(self) -> bool: + def read_stopped_train(self) -> Optional[bool]: """ Whether or not to read a unique number associated with the train currently stopped at the station. @@ -281,7 +286,7 @@ def read_stopped_train(self) -> bool: return self.control_behavior.read_stopped_train @read_stopped_train.setter - def read_stopped_train(self, value: bool): + def read_stopped_train(self, value: Optional[bool]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -297,7 +302,7 @@ def read_stopped_train(self, value: bool): # ========================================================================= @property - def train_stopped_signal(self) -> SignalID: + def train_stopped_signal(self) -> Optional[SignalID]: """ What signal to output the unique train ID if a train is currently stopped at a station. @@ -305,7 +310,7 @@ def train_stopped_signal(self) -> SignalID: return self.control_behavior.train_stopped_signal @train_stopped_signal.setter - def train_stopped_signal(self, value: Union[str, SignalID]): + def train_stopped_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -321,7 +326,7 @@ def train_stopped_signal(self, value: Union[str, SignalID]): # ========================================================================= @property - def signal_limits_trains(self) -> bool: + def signal_limits_trains(self) -> Optional[bool]: """ Whether or not an external signal should limit the number of trains that can use this stop. @@ -329,7 +334,7 @@ def signal_limits_trains(self) -> bool: return self.control_behavior.set_trains_limit @signal_limits_trains.setter - def signal_limits_trains(self, value: bool): + def signal_limits_trains(self, value: Optional[bool]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -345,14 +350,14 @@ def signal_limits_trains(self, value: bool): # ========================================================================= @property - def trains_limit_signal(self) -> SignalID: + def trains_limit_signal(self) -> Optional[SignalID]: """ What signal to read to limit the number of trains that can use this stop. """ return self.control_behavior.trains_limit_signal @trains_limit_signal.setter - def trains_limit_signal(self, value: Union[str, SignalID]): + def trains_limit_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -368,14 +373,14 @@ def trains_limit_signal(self, value: Union[str, SignalID]): # ========================================================================= @property - def read_trains_count(self) -> bool: + def read_trains_count(self) -> Optional[bool]: """ Whether or not to read the number of trains that currently use this stop. """ return self.control_behavior.read_trains_count @read_trains_count.setter - def read_trains_count(self, value: bool): + def read_trains_count(self, value: Optional[bool]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -391,7 +396,7 @@ def read_trains_count(self, value: bool): # ========================================================================= @property - def trains_count_signal(self) -> SignalID: + def trains_count_signal(self) -> Optional[SignalID]: """ What signal to use to output the current number of trains that use this stop. @@ -399,7 +404,7 @@ def trains_count_signal(self) -> SignalID: return self.control_behavior.trains_count_signal @trains_count_signal.setter - def trains_count_signal(self, value: Union[str, SignalID]): + def trains_count_signal(self, value: Union[str, SignalID, None]): if self.validate_assignment: result = attempt_and_reissue( self, @@ -414,8 +419,7 @@ def trains_count_signal(self, value: Union[str, SignalID]): # ========================================================================= - def merge(self, other): - # type: (TrainStop) -> None + def merge(self, other: "TrainStop"): super(TrainStop, self).merge(other) self.station = other.station @@ -425,7 +429,7 @@ def merge(self, other): __hash__ = Entity.__hash__ - def __eq__(self, other) -> bool: + def __eq__(self, other: "TrainStop") -> bool: return ( super().__eq__(other) and self.station == other.station diff --git a/draftsman/prototypes/transport_belt.py b/draftsman/prototypes/transport_belt.py index 17ea137..0ffce4e 100644 --- a/draftsman/prototypes/transport_belt.py +++ b/draftsman/prototypes/transport_belt.py @@ -1,7 +1,4 @@ # transport_belt.py -# -*- encoding: utf-8 -*- - -from __future__ import unicode_literals from draftsman.classes.entity import Entity from draftsman.classes.mixins import ( @@ -66,8 +63,8 @@ def __init__( position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), direction: Direction = Direction.NORTH, - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -95,8 +92,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/turret.py b/draftsman/prototypes/turret.py index 21f2194..4baf4e3 100644 --- a/draftsman/prototypes/turret.py +++ b/draftsman/prototypes/turret.py @@ -49,14 +49,14 @@ def __init__( position=position, tile_position=tile_position, direction=direction, + items=items, tags=tags, **kwargs ) self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/underground_belt.py b/draftsman/prototypes/underground_belt.py index 57acf85..acde791 100644 --- a/draftsman/prototypes/underground_belt.py +++ b/draftsman/prototypes/underground_belt.py @@ -17,6 +17,7 @@ class UndergroundBelt(IOTypeMixin, DirectionalMixin, Entity): """ class Format(IOTypeMixin.Format, DirectionalMixin.Format, Entity.Format): + model_config = ConfigDict(title="UndergroundBelt") def __init__( @@ -57,8 +58,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/underground_pipe.py b/draftsman/prototypes/underground_pipe.py index 2f093b7..e1b5bb9 100644 --- a/draftsman/prototypes/underground_pipe.py +++ b/draftsman/prototypes/underground_pipe.py @@ -50,8 +50,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/prototypes/wall.py b/draftsman/prototypes/wall.py index 08a7d85..e5c9f91 100644 --- a/draftsman/prototypes/wall.py +++ b/draftsman/prototypes/wall.py @@ -14,7 +14,13 @@ from draftsman.data.entities import walls -from pydantic import ConfigDict, Field +from pydantic import ( + ConfigDict, + Field, + field_validator, + ValidatorFunctionWrapHandler, + ValidationInfo, +) from typing import Any, Literal, Optional, Union @@ -76,8 +82,8 @@ def __init__( name: str = walls[0], position: Union[Vector, PrimitiveVector] = None, tile_position: Union[Vector, PrimitiveVector] = (0, 0), - connections: Connections = Connections(), - control_behavior: Format.ControlBehavior = Format.ControlBehavior(), + connections: Connections = {}, + control_behavior: Format.ControlBehavior = {}, tags: dict[str, Any] = {}, validate: Union[ ValidationMode, Literal["none", "minimum", "strict", "pedantic"] @@ -105,8 +111,7 @@ def __init__( self.validate_assignment = validate_assignment - if validate: - self.validate(mode=validate).reissue_all(stacklevel=3) + self.validate(mode=validate).reissue_all(stacklevel=3) # ========================================================================= diff --git a/draftsman/signatures.py b/draftsman/signatures.py index 9ddae4c..2f8be0c 100644 --- a/draftsman/signatures.py +++ b/draftsman/signatures.py @@ -17,14 +17,18 @@ get_signal_type, pure_virtual, ) -from draftsman.data import items, signals +from draftsman.data import entities, fluids, items, signals, tiles from draftsman.error import InvalidMapperError, InvalidSignalError from draftsman.warning import ( BarWarning, MalformedSignalWarning, PureVirtualDisallowedWarning, + UnknownEntityWarning, + UnknownFluidWarning, UnknownKeywordWarning, + UnknownItemWarning, UnknownSignalWarning, + UnknownTileWarning, ) from typing_extensions import Annotated @@ -34,9 +38,11 @@ ConfigDict, Field, GetJsonSchemaHandler, - PrivateAttr, RootModel, ValidationInfo, + ValidationError, + ValidatorFunctionWrapHandler, + WrapValidator, field_validator, model_validator, model_serializer, @@ -46,138 +52,134 @@ from pydantic_core import CoreSchema from textwrap import dedent from thefuzz import process -from typing import Any, ClassVar, Literal, Optional, Sequence -from functools import lru_cache -import sys -import types -import typing - -try: - from typing import get_args, get_origin # type: ignore -except ImportError: - from typing_extensions import get_args, get_origin - - -if sys.version_info >= (3, 10): - - def _is_union(origin): - return origin is typing.Union or origin is types.UnionType - -else: - - def _is_union(origin): - return origin is typing.Union - - -def recursive_construct(model_class: BaseModel, **input_data) -> BaseModel: - def handle_annotation(annotation: type, value: Any): - # print(annotation, value) - try: - if issubclass(annotation, BaseModel): - # print("yes!") - return recursive_construct(annotation, **value) - except Exception as e: - # print(type(e).__name__, e) - # print("issue with BaseModel".format(annotation)) - pass - try: - if issubclass(annotation, RootModel): - # print("rootyes!") - return recursive_construct(annotation, root=value) - except Exception as e: - # print(type(e).__name__, e) - # print("issue with RootModel") - pass - - origin = get_origin(annotation) - # print(origin) - - if origin is None: - return value - elif _is_union(origin): - # print("optional") - args = get_args(annotation) - for arg in args: - # print("\t", arg) - result = handle_annotation(arg, value) - # print("union result: {}".format(result)) - if result != value: - # print("early exit") - return result - # Otherwise - # print("otherwise") - return value - elif origin is typing.Literal: - # print("literal") - return value - elif isinstance(origin, (str, bytes)): - # print("string") - return value - elif issubclass(origin, typing.Tuple): - # print("tuple") - args = get_args(annotation) - if isinstance(args[-1], type(Ellipsis)): - # format: tuple[T, ...] - member_type = args[0] - return tuple(handle_annotation(member_type, v) for v in value) - else: - # format: tuple[A, B, C] - return tuple( - handle_annotation(member_type, value[i]) - for i, member_type in enumerate(args) - ) - elif issubclass(origin, typing.Sequence): - # print("list") - member_type = get_args(annotation)[0] - # print(member_type) - # print(value) - result = [handle_annotation(member_type, v) for v in value] - # print("result: {}".format(result)) - return result - else: - return value - - m = model_class.__new__(model_class) - fields_values: dict[str, typing.Any] = {} - defaults: dict[str, typing.Any] = {} - for name, field in model_class.model_fields.items(): - # print("\t", name, field.annotation) - if field.alias and field.alias in input_data: - fields_values[name] = handle_annotation( - field.annotation, input_data.pop(field.alias) - ) - elif name in input_data: - result = handle_annotation(field.annotation, input_data.pop(name)) - # print("outer_result: {}".format(result)) - fields_values[name] = result - elif not field.is_required(): - # print("\tdefault") - defaults[name] = field.get_default(call_default_factory=True) - _fields_set = set(fields_values.keys()) - fields_values.update(defaults) - - # print(fields_values) - - _extra: dict[str, typing.Any] | None = None - if model_class.model_config.get("extra") == "allow": - _extra = {} - for k, v in input_data.items(): - _extra[k] = v - else: - fields_values.update(input_data) - object.__setattr__(m, "__dict__", fields_values) - object.__setattr__(m, "__pydantic_fields_set__", _fields_set) - if not model_class.__pydantic_root_model__: - object.__setattr__(m, "__pydantic_extra__", _extra) +from typing import Any, Literal, Optional, Sequence + +# try: +# from typing import get_args, get_origin # type: ignore +# except ImportError: +# from typing_extensions import get_args, get_origin + + +# if sys.version_info >= (3, 10): + +# def _is_union(origin): +# return origin is typing.Union or origin is types.UnionType + +# else: + +# def _is_union(origin): +# return origin is typing.Union + + +# def recursive_construct(model_class: BaseModel, **input_data) -> BaseModel: +# def handle_annotation(annotation: type, value: Any): +# # print(annotation, value) +# try: +# if issubclass(annotation, BaseModel): +# # print("yes!") +# return recursive_construct(annotation, **value) +# except Exception as e: +# # print(type(e).__name__, e) +# # print("issue with BaseModel".format(annotation)) +# pass +# try: +# if issubclass(annotation, RootModel): +# # print("rootyes!") +# return recursive_construct(annotation, root=value) +# except Exception as e: +# # print(type(e).__name__, e) +# # print("issue with RootModel") +# pass + +# origin = get_origin(annotation) +# # print(origin) + +# if origin is None: +# return value +# elif _is_union(origin): +# # print("optional") +# args = get_args(annotation) +# for arg in args: +# # print("\t", arg) +# result = handle_annotation(arg, value) +# # print("union result: {}".format(result)) +# if result != value: +# # print("early exit") +# return result +# # Otherwise +# # print("otherwise") +# return value +# elif origin is typing.Literal: +# # print("literal") +# return value +# elif isinstance(origin, (str, bytes)): +# # print("string") +# return value +# elif issubclass(origin, typing.Tuple): +# # print("tuple") +# args = get_args(annotation) +# if isinstance(args[-1], type(Ellipsis)): +# # format: tuple[T, ...] +# member_type = args[0] +# return tuple(handle_annotation(member_type, v) for v in value) +# else: +# # format: tuple[A, B, C] +# return tuple( +# handle_annotation(member_type, value[i]) +# for i, member_type in enumerate(args) +# ) +# elif issubclass(origin, typing.Sequence): +# # print("list") +# member_type = get_args(annotation)[0] +# # print(member_type) +# # print(value) +# result = [handle_annotation(member_type, v) for v in value] +# # print("result: {}".format(result)) +# return result +# else: +# return value + +# m = model_class.__new__(model_class) +# fields_values: dict[str, typing.Any] = {} +# defaults: dict[str, typing.Any] = {} +# for name, field in model_class.model_fields.items(): +# # print("\t", name, field.annotation) +# if field.alias and field.alias in input_data: +# fields_values[name] = handle_annotation( +# field.annotation, input_data.pop(field.alias) +# ) +# elif name in input_data: +# result = handle_annotation(field.annotation, input_data.pop(name)) +# # print("outer_result: {}".format(result)) +# fields_values[name] = result +# elif not field.is_required(): +# # print("\tdefault") +# defaults[name] = field.get_default(call_default_factory=True) +# _fields_set = set(fields_values.keys()) +# fields_values.update(defaults) + +# # print(fields_values) + +# _extra: dict[str, typing.Any] | None = None +# if model_class.model_config.get("extra") == "allow": +# _extra = {} +# for k, v in input_data.items(): +# _extra[k] = v +# else: +# fields_values.update(input_data) +# object.__setattr__(m, "__dict__", fields_values) +# object.__setattr__(m, "__pydantic_fields_set__", _fields_set) +# if not model_class.__pydantic_root_model__: +# object.__setattr__(m, "__pydantic_extra__", _extra) - if model_class.__pydantic_post_init__: - m.model_post_init(None) - elif not model_class.__pydantic_root_model__: - # Note: if there are any private attributes, cls.__pydantic_post_init__ would exist - # Since it doesn't, that means that `__pydantic_private__` should be set to None - object.__setattr__(m, "__pydantic_private__", None) +# if model_class.__pydantic_post_init__: +# m.model_post_init(None) +# elif not model_class.__pydantic_root_model__: +# # Note: if there are any private attributes, cls.__pydantic_post_init__ would exist +# # Since it doesn't, that means that `__pydantic_private__` should be set to None +# object.__setattr__(m, "__pydantic_private__", None) - return m +# return m def get_suggestion(name, choices, n=3, cutoff=60): @@ -191,7 +193,7 @@ def get_suggestion(name, choices, n=3, cutoff=60): elif len(suggestions) == 1: return "; did you mean '{}'?".format(suggestions[0]) else: - return "; did you mean one of {}?".format(suggestions) + return "; did you mean one of {}?".format(suggestions) # pragma: no coverage # return "; did you mean one of {}?".format(", ".join(["or " + str(item) if i == len(suggestions) - 1 else str(item) for i, item in enumerate(suggestions)])) @@ -206,11 +208,48 @@ def get_suggestion(name, choices, n=3, cutoff=60): uint64 = Annotated[int, Field(..., ge=0, lt=2**64)] -def known_item(v: str) -> str: - if v not in items.raw: - raise ValueError(v) - return v -ItemName = Annotated[str, AfterValidator(known_item)] +def known_name(type: str, structure: dict, issued_warning): + """ + Validator function builder for any type of unknown name. + """ + + def inside_func(value: str, info: ValidationInfo) -> str: + if not info.context: + return value + if info.context["mode"] <= ValidationMode.MINIMUM: + return value + + warning_list: list = info.context["warning_list"] + + if value not in structure: + warning_list.append( + issued_warning( + "Unknown {} '{}'{}".format( + type, value, get_suggestion(value, structure.keys(), n=1) + ) + ) + ) + + return value + + return inside_func + + +ItemName = Annotated[ + str, AfterValidator(known_name("item", items.raw, UnknownItemWarning)) +] +SignalName = Annotated[ + str, AfterValidator(known_name("signal", signals.raw, UnknownSignalWarning)) +] +EntityName = Annotated[ + str, AfterValidator(known_name("entity", entities.raw, UnknownEntityWarning)) +] +FluidName = Annotated[ + str, AfterValidator(known_name("fluid", fluids.raw, UnknownFluidWarning)) +] +TileName = Annotated[ + str, AfterValidator(known_name("tile", tiles.raw, UnknownTileWarning)) +] class DraftsmanBaseModel(BaseModel): @@ -230,9 +269,28 @@ class DraftsmanBaseModel(BaseModel): # ] def true_model_fields(cls): return { - (v.alias if v.alias is not None else k): k for k, v in cls.model_fields.items() + (v.alias if v.alias is not None else k): k + for k, v in cls.model_fields.items() } + @field_validator("*", mode="wrap") + def construct_fields( + cls, value: Any, handler: ValidatorFunctionWrapHandler, info: ValidationInfo + ): + if info.context and "construction" in info.context: + try: + return handler(value) + except ValidationError: + return value + # TODO: swap the above with just returning the value + # Currently we're doing the validation twice, first a minimum pass to coerce + # everything we can to their respective BaseModels, and the second time to + # actually validate all the data and run the custom validation functions + # Ideally, this could all happen in one step if we copy the code from + # validate/add the "construction" keyword to the context of validate + else: + return handler(value) + @model_validator(mode="after") def warn_unused_arguments(self, info: ValidationInfo): """ @@ -242,32 +300,37 @@ def warn_unused_arguments(self, info: ValidationInfo): """ if not info.context: return self - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return self - # We only want to issue this particular warning if we're setting an + obj = info.context["object"] + + # If we're creating a generic `Entity` (in the case where Draftsman + # cannot really know what entity is being imported) then we don't want + # to issue the following warnings, since we don't want to make + # assertions where we don't have enough information + if obj.unknown_format: + return self + + # We also only want to issue this particular warning if we're setting an # assignment of a subfield, or if we're doing a full scale `validate()` # function call - obj = info.context["object"] if type(obj).Format is type(self) and info.context["assignment"]: return self if self.model_extra: warning_list: list = info.context["warning_list"] - issue = UnknownKeywordWarning( - "'{}' object has no attribute(s) {}; allowed fields are {}".format( - self.model_config.get("title", type(self).__name__), - list(self.model_extra.keys()), - self.true_model_fields(), + warning_list.append( + UnknownKeywordWarning( + "'{}' object has no attribute(s) {}; allowed fields are {}".format( + self.model_config.get("title", type(self).__name__), + list(self.model_extra.keys()), + self.true_model_fields().keys(), + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return self # Permit accessing via indexing @@ -298,14 +361,14 @@ def normalize_description(input_obj: str) -> str: input_obj["description"] = dedent(input_obj["description"]).strip() normalize_description(json_schema) - if "properties" in json_schema: - for property_spec in json_schema["properties"].values(): - normalize_description(property_spec) - if "items" in json_schema: - normalize_description(json_schema["items"]) + # if "properties" in json_schema: # Maybe not needed? + for property_spec in json_schema["properties"].values(): + normalize_description(property_spec) + # if "items" in json_schema: # Maybe not needed? + # normalize_description(json_schema["items"]) return json_schema - + # def __repr__(self): # TODO # return "{}{{{}}}".format(__class__.__name__, super().__repr__()) @@ -318,19 +381,25 @@ def normalize_description(input_obj: str) -> str: ) -class DraftsmanRootModel(RootModel): - """ - TODO - """ +# class DraftsmanRootModel(RootModel): +# """ +# TODO +# """ - # Permit accessing via indexing - def __getitem__(self, key): - getattr(self.root, key) +# # Permit accessing via indexing +# def __getitem__(self, key): +# return self.root[key] - def __setitem__(self, key, value): - setattr(self.root, key, value) +# def __setitem__(self, key, value): +# self.root[key] = value + +# def __delitem__(self, key): +# del self.root[key] + +# def __len__(self) -> int: +# return len(self.root) - model_config = ConfigDict(revalidate_instances="always") +# model_config = ConfigDict(revalidate_instances="always") # ACCUMULATOR_CONTROL_BEHAVIOR = Schema( @@ -700,37 +769,37 @@ class Mapper(DraftsmanBaseModel): # super().__setitem__(self._alias_map[key], value) -class Mappers(DraftsmanRootModel): - root: list[Mapper] - - # @model_validator(mode="before") - # @classmethod - # def normalize_mappers(cls, value: Any): - # if isinstance(value, Sequence): - # for i, mapper in enumerate(value): - # if isinstance(value, (tuple, list)): - # value[i] = {"index": i} - # if mapper[0]: - # value[i]["from"] = mapper_dict(mapper[0]) - # if mapper[1]: - # value[i]["to"] = mapper_dict(mapper[1]) - - # @validator("__root__", pre=True) - # def normalize_mappers(cls, mappers): - # if mappers is None: - # return mappers - # for i, mapper in enumerate(mappers): - # if isinstance(mapper, (tuple, list)): - # mappers[i] = {"index": i} - # if mapper[0]: - # mappers[i]["from"] = mapping_dict(mapper[0]) - # if mapper[1]: - # mappers[i]["to"] = mapping_dict(mapper[1]) - # return mappers +# class Mappers(DraftsmanRootModel): +# root: list[Mapper] + +# # @model_validator(mode="before") +# # @classmethod +# # def normalize_mappers(cls, value: Any): +# # if isinstance(value, Sequence): +# # for i, mapper in enumerate(value): +# # if isinstance(value, (tuple, list)): +# # value[i] = {"index": i} +# # if mapper[0]: +# # value[i]["from"] = mapper_dict(mapper[0]) +# # if mapper[1]: +# # value[i]["to"] = mapper_dict(mapper[1]) + +# # @validator("__root__", pre=True) +# # def normalize_mappers(cls, mappers): +# # if mappers is None: +# # return mappers +# # for i, mapper in enumerate(mappers): +# # if isinstance(mapper, (tuple, list)): +# # mappers[i] = {"index": i} +# # if mapper[0]: +# # mappers[i]["from"] = mapping_dict(mapper[0]) +# # if mapper[1]: +# # mappers[i]["to"] = mapping_dict(mapper[1]) +# # return mappers class SignalID(DraftsmanBaseModel): - name: Optional[str] = Field( + name: Optional[SignalName] = Field( ..., description=""" Name of the signal. If omitted, the signal is treated as no signal and @@ -762,37 +831,37 @@ def init_from_string(cls, input): else: return input - @field_validator("name") - @classmethod - def check_name_recognized(cls, value: str, info: ValidationInfo): - """ - We might be provided with a signal which has all the information - necessary to pass validation, but will be otherwise unrecognized by - Draftsman (in it's current configuration at least). Issue a warning - for every unknown signal. - """ - # TODO: check a table to make sure we don't warn about the same unknown - # signal multiple times - if not info.context: - return value - if info.context["mode"] is ValidationMode.MINIMUM: - return value - - warning_list: list = info.context["warning_list"] - - if value not in signals.raw: - issue = UnknownSignalWarning( - "Unknown signal '{}'{}".format( - value, get_suggestion(value, signals.raw.keys(), n=1) - ) - ) - - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) + # @field_validator("name") + # @classmethod + # def check_name_recognized(cls, value: str, info: ValidationInfo): + # """ + # We might be provided with a signal which has all the information + # necessary to pass validation, but will be otherwise unrecognized by + # Draftsman (in it's current configuration at least). Issue a warning + # for every unknown signal. + # """ + # # TODO: check a table to make sure we don't warn about the same unknown + # # signal multiple times + # if not info.context: + # return value + # if info.context["mode"] is ValidationMode.MINIMUM: + # return value + + # warning_list: list = info.context["warning_list"] + + # if value not in signals.raw: + # issue = UnknownSignalWarning( + # "Unknown signal '{}'{}".format( + # value, get_suggestion(value, signals.raw.keys(), n=1) + # ) + # ) + + # if info.context["mode"] is ValidationMode.PEDANTIC: + # raise ValueError(issue) from None + # else: + # warning_list.append(issue) - return value + # return value @model_validator(mode="after") @classmethod @@ -805,7 +874,7 @@ def check_type_matches_name(cls, value: "SignalID", info: ValidationInfo): """ if not info.context: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value warning_list: list = info.context["warning_list"] @@ -813,17 +882,14 @@ def check_type_matches_name(cls, value: "SignalID", info: ValidationInfo): if value["name"] in signals.raw: expected_type = get_signal_type(value["name"]) if expected_type != value["type"]: - issue = MalformedSignalWarning( - "Known signal '{}' was given a mismatching type (expected '{}', found '{}')".format( - value["name"], expected_type, value["type"] + warning_list.append( + MalformedSignalWarning( + "Known signal '{}' was given a mismatching type (expected '{}', found '{}')".format( + value["name"], expected_type, value["type"] + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise AssertionError(issue) from None - else: - warning_list.append(issue) - return value # @model_serializer @@ -853,28 +919,28 @@ class Icon(DraftsmanBaseModel): ) # TODO: is it numerical order which determines appearance, or order in parent list? -class Icons(DraftsmanRootModel): - root: list[Icon] = Field( - ..., - max_length=4, - description=""" - The list of all icons used by this object. Hard-capped to 4 entries - total; having more than 4 will raise an error in import. - """, - ) +# class Icons(DraftsmanRootModel): +# root: list[Icon] = Field( +# ..., +# max_length=4, +# description=""" +# The list of all icons used by this object. Hard-capped to 4 entries +# total; having more than 4 will raise an error in import. +# """, +# ) - @model_validator(mode="before") - def normalize_icons(cls, value: Any): - if isinstance(value, Sequence): - result = [None] * len(value) - for i, signal in enumerate(value): - if isinstance(signal, str): - result[i] = {"index": i + 1, "signal": signal} - else: - result[i] = signal - return result - else: - return value +# @model_validator(mode="before") +# def normalize_icons(cls, value: Any): +# if isinstance(value, Sequence): +# result = [None] * len(value) +# for i, signal in enumerate(value): +# if isinstance(signal, str): +# result[i] = {"index": i + 1, "signal": signal} +# else: +# result[i] = signal +# return result +# else: +# return value class Color(DraftsmanBaseModel): @@ -1032,24 +1098,12 @@ def normalize_comparator_python_equivalents(cls, input: Any): return input -class WaitCondition(DraftsmanBaseModel): - type: str - compare_type: str - ticks: Optional[int] = None # TODO dimension - condition: Optional[Condition] = None # TODO: correct annotation - - -class Stop(DraftsmanBaseModel): - station: str - wait_conditions: list[WaitCondition] # TODO: optional? - - class EntityFilter(DraftsmanBaseModel): - name: str = Field( + name: EntityName = Field( ..., description=""" The name of a valid deconstructable entity. - """ + """, ) index: Optional[uint64] = Field( description=""" @@ -1062,11 +1116,11 @@ class EntityFilter(DraftsmanBaseModel): class TileFilter(DraftsmanBaseModel): - name: str = Field( + name: TileName = Field( ..., description=""" The name of a valid deconstructable tile. - """ + """, ) index: Optional[uint64] = Field( description=""" @@ -1079,12 +1133,12 @@ class TileFilter(DraftsmanBaseModel): class CircuitConnectionPoint(DraftsmanBaseModel): - entity_id: uint64 + entity_id: Association.Format circuit_id: Optional[Literal[1, 2]] = None class WireConnectionPoint(DraftsmanBaseModel): - entity_id: uint64 + entity_id: Association.Format wire_id: Optional[Literal[0, 1]] = None @@ -1120,44 +1174,48 @@ class CircuitConnections(DraftsmanBaseModel): # return item in self._alias_map and -class Filters(DraftsmanRootModel): - class FilterEntry(DraftsmanBaseModel): - index: int64 = Field( - ..., description="""Numeric index of a filter entry, 1-based.""" - ) - name: str = Field( # TODO: ItemID - ..., description="""Name of the item to filter.""" - ) +# class Filters(DraftsmanRootModel): +class FilterEntry(DraftsmanBaseModel): + index: int64 = Field( + ..., description="""Numeric index of a filter entry, 1-based.""" + ) + name: ItemName = Field(..., description="""Name of the item to filter.""") - @field_validator("index") - @classmethod - def ensure_within_filter_count(cls, value: int, info: ValidationInfo): - if not info.context: - return value + @field_validator("index") + @classmethod + def ensure_within_filter_count(cls, value: int, info: ValidationInfo): + """ """ + if not info.context: + return value + if info.context["mode"] <= ValidationMode.MINIMUM: + return value - entity = info.context["object"] - if entity.filter_count is not None and value >= entity.filter_count: - raise ValueError( - "'{}' exceeds the allowable range for filter slot indices [0, {}) for this entity ('{}')".format( - value, entity.filter_count, entity.name - ) + entity = info.context["object"] + + if entity.filter_count is not None and value > entity.filter_count: + raise ValueError( + "'{}' exceeds the allowable range for filter slot indices [0, {}) for this entity ('{}')".format( + value, entity.filter_count, entity.name ) + ) - return value + return value - root: list[FilterEntry] + # root: list[FilterEntry] - @model_validator(mode="before") - @classmethod - def normalize_validate(cls, value: Any): - result = [] - if isinstance(value, (list, tuple)): - for i, entry in enumerate(value): - if isinstance(entry, str): - result.append({"index": i + 1, "name": entry}) - else: - result.append(entry) - return result + # @model_validator(mode="before") + # @classmethod + # def normalize_validate(cls, value: Any): + # if isinstance(value, (list, tuple)): + # result = [] + # for i, entry in enumerate(value): + # if isinstance(entry, str): + # result.append({"index": i + 1, "name": entry}) + # else: + # result.append(entry) + # return result + # else: + # return value # @model_serializer # def normalize_construct(self): @@ -1175,23 +1233,20 @@ def ensure_bar_less_than_inventory_size( ): if not info.context or value is None: return value - if info.context["mode"] == ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.STRICT: return value warning_list: list = info.context["warning_list"] entity = info.context["object"] if entity.inventory_size and value >= entity.inventory_size: - issue = BarWarning( - "Bar index ({}) exceeds the container's inventory size ({})".format( - value, entity.inventory_size - ), + warning_list.append( + BarWarning( + "Bar index ({}) exceeds the container's inventory size ({})".format( + value, entity.inventory_size + ), + ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise issue - else: - warning_list.append(issue) - return value @@ -1223,63 +1278,77 @@ def ensure_bar_less_than_inventory_size( # return bar -class InventoryFilters(DraftsmanBaseModel): - filters: Optional[Filters] = Field( - None, - description=""" - Any reserved item filter slots in the container's inventory. - """, +# class InventoryFilters(DraftsmanBaseModel): +# filters: Optional[list[FilterEntry]] = Field( +# None, +# description=""" +# Any reserved item filter slots in the container's inventory. +# """, +# ) +# bar: Optional[uint16] = Field( +# None, +# description=""" +# Limiting bar on this container's inventory. +# """, +# ) + +# @field_validator("filters", mode="before") +# @classmethod +# def normalize_filters(cls, value: Any): +# if isinstance(value, (list, tuple)): +# result = [] +# for i, entry in enumerate(value): +# if isinstance(entry, str): +# result.append({"index": i + 1, "name": entry}) +# else: +# result.append(entry) +# return result +# else: +# return value + +# @field_validator("bar") +# @classmethod +# def ensure_less_than_inventory_size( +# cls, bar: Optional[uint16], info: ValidationInfo +# ): +# return ensure_bar_less_than_inventory_size(cls, bar, info) + + +# class RequestFilters(DraftsmanRootModel): +class RequestFilter(DraftsmanBaseModel): + index: int64 = Field( + ..., description="""Numeric index of the logistics request, 1-based.""" ) - bar: Optional[uint16] = Field( - None, + name: ItemName = Field( + ..., description="""The name of the item to request from logistics.""" + ) + count: Optional[int64] = Field( + 1, description=""" - Limiting bar on this container's inventory. + The amount of the item to request. Optional on import to Factorio, + but always included on export from Factorio. If omitted, will + default to a count of 1. """, ) - @field_validator("bar") - @classmethod - def ensure_less_than_inventory_size( - cls, bar: Optional[uint16], info: ValidationInfo - ): - return ensure_bar_less_than_inventory_size(cls, bar, info) - - -class RequestFilters(DraftsmanRootModel): - class Request(DraftsmanBaseModel): - index: int64 = Field( - ..., description="""Numeric index of the logistics request, 1-based.""" - ) - name: str = Field( # TODO: ItemName - ..., description="""The name of the item to request from logistics.""" - ) - count: Optional[int64] = Field( - 1, - description=""" - The amount of the item to request. Optional on import to Factorio, - but always included on export from Factorio. If omitted, will - default to a count of 1. - """, - ) + # root: list[Request] - root: list[Request] - - @model_validator(mode="before") - @classmethod - def normalize_validate(cls, value: Any): - if value is None: - return value + # @model_validator(mode="before") + # @classmethod + # def normalize_validate(cls, value: Any): + # if value is None: + # return value - result = [] - if isinstance(value, list): - for i, entry in enumerate(value): - if isinstance(entry, (tuple, list)): - result.append({"index": i + 1, "name": entry[0], "count": entry[1]}) - else: - result.append(entry) - return result - else: - return value + # result = [] + # if isinstance(value, list): + # for i, entry in enumerate(value): + # if isinstance(entry, (tuple, list)): + # result.append({"index": i + 1, "name": entry[0], "count": entry[1]}) + # else: + # result.append(entry) + # return result + # else: + # return value # @model_serializer # def normalize_construct(self): @@ -1328,6 +1397,8 @@ def ensure_index_within_range(cls, value: int64, info: ValidationInfo): """ if not info.context: return value + if info.context["mode"] <= ValidationMode.MINIMUM: + return value entity = info.context["object"] @@ -1335,7 +1406,8 @@ def ensure_index_within_range(cls, value: int64, info: ValidationInfo): if entity.item_slot_count is None: return value - if not 0 <= value < entity.item_slot_count: + # TODO: what happens if index is 0? + if not 0 < value <= entity.item_slot_count: raise ValueError( "Signal 'index' ({}) must be in the range [0, {})".format( value, entity.item_slot_count @@ -1353,21 +1425,18 @@ def ensure_not_pure_virtual(cls, value: Optional[SignalID], info: ValidationInfo """ if not info.context or value is None: return value - if info.context["mode"] is ValidationMode.MINIMUM: + if info.context["mode"] <= ValidationMode.MINIMUM: return value warning_list: list = info.context["warning_list"] if value.name in pure_virtual: - issue = PureVirtualDisallowedWarning( - "Cannot set pure virtual signal '{}' in a constant combinator".format( - value.name + warning_list.append( + PureVirtualDisallowedWarning( + "Cannot set pure virtual signal '{}' in a constant combinator".format( + value.name + ) ) ) - if info.context["mode"] is ValidationMode.PEDANTIC: - raise ValueError(issue) from None - else: - warning_list.append(issue) - return value diff --git a/draftsman/types.py b/draftsman/types.py new file mode 100644 index 0000000..8537cc9 --- /dev/null +++ b/draftsman/types.py @@ -0,0 +1,11 @@ +# types.py + +from draftsman.entity import Locomotive, CargoWagon, FluidWagon, ArtilleryWagon +from draftsman.entity import TransportBelt, UndergroundBelt, Splitter + +from typing import Union + + +RollingStock = Union[Locomotive, CargoWagon, FluidWagon, ArtilleryWagon] + +Belts = Union[TransportBelt, UndergroundBelt, Splitter] \ No newline at end of file diff --git a/draftsman/utils.py b/draftsman/utils.py index 8385d15..6604575 100644 --- a/draftsman/utils.py +++ b/draftsman/utils.py @@ -15,11 +15,11 @@ import math from functools import wraps import six -from typing import TYPE_CHECKING, Optional, Union +from typing import Optional, Union, TYPE_CHECKING import warnings import zlib -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no coverage from draftsman.classes.entity_like import EntityLike from draftsman.entity import Entity @@ -35,13 +35,11 @@ class Shape: single attribute, a PrimitiveVector :py:attr:`position`. """ - def __init__(self, position): - # type: (Vector) -> None + def __init__(self, position: Vector): self.position = Vector.from_other(position) @abstractmethod - def overlaps(self, other): # pragma: no coverage - # type: (Shape) -> bool + def overlaps(self, other: "Shape") -> bool: # pragma: no coverage """ Determines if this :py:class:`.Shape` overlaps with another :py:class:`.Shape`. @@ -62,8 +60,14 @@ class AABB(Shape): convenience functions. """ - def __init__(self, x1, y1, x2, y2, position=[0, 0]): - # type: (float, float, float, float, Vector) -> None + def __init__( + self, + x1: float, + y1: float, + x2: float, + y2: float, + position: Union[Vector, PrimitiveVector] = (0, 0), + ): """ TODO @@ -100,8 +104,7 @@ def __init__(self, x1, y1, x2, y2, position=[0, 0]): self.normals = [[0, -1], [1, 0], [0, 1], [-1, 0]] @staticmethod - def from_other(aabb): - # type: (Union[list[float], tuple[float]]) -> AABB + def from_other(aabb: Union[list[float], tuple[float]]) -> "AABB": """ Converts a ``PrimitiveAABB`` to an :py:class:`.AABB`. @@ -117,8 +120,7 @@ def from_other(aabb): raise TypeError("Could not resolve '{}' to an AABB".format(aabb)) @property - def world_top_left(self): - # type: () -> PrimitiveVector + def world_top_left(self) -> PrimitiveVector: """ Gets the top left of the :py:class:`.AABB`, as offset by it's ``position``. As the attribute suggests, this is typically the top-left of the box in @@ -132,8 +134,7 @@ def world_top_left(self): ] @property - def world_bot_right(self): - # type: () -> PrimitiveVector + def world_bot_right(self) -> PrimitiveVector: """ Gets the bottom right of the :py:class:`.AABB`, as offset by it's ``position``. As the attribute suggests, this is typically the top-left @@ -146,8 +147,7 @@ def world_bot_right(self): self.bot_right[1] + self.position[1], ] - def overlaps(self, other): - # type: (Shape) -> bool + def overlaps(self, other: "Shape") -> bool: if isinstance(other, AABB): return aabb_overlaps_aabb(self, other) elif isinstance(other, Rectangle): @@ -155,8 +155,7 @@ def overlaps(self, other): else: raise TypeError("Could not resolve '{}' to a Shape".format(other)) - def get_points(self): - # type: () -> list[Vector] + def get_points(self) -> list[PrimitiveVector]: """ Returns all 4 points associated with the corners of the AABB, used for determining collision. @@ -169,8 +168,7 @@ def get_points(self): for point in self.points ] - def get_bounding_box(self): - # type: () -> AABB + def get_bounding_box(self) -> "AABB": """ Returns the minimum-encompassing bounding box around this AABB, which happens to be an new AABB offset by this AABB's position. Used for @@ -189,8 +187,7 @@ def get_bounding_box(self): bounding_box.bot_right[1] += self.position[1] return bounding_box - def rotate(self, amt): - # type: (int) -> AABB + def rotate(self, amt: int) -> "AABB": """ Rotates the :py:class:`.AABB` by increments of 90 degrees and returns a new transformed instance. @@ -231,8 +228,7 @@ def rotate(self, amt): return AABB(top_left[0], top_left[1], bot_right[0], bot_right[1], self.position) - def __eq__(self, other): - # type: (AABB) -> bool + def __eq__(self, other: "AABB") -> bool: return ( isinstance(other, AABB) and self.position == other.position @@ -240,12 +236,11 @@ def __eq__(self, other): and self.bot_right == other.bot_right ) - def __add__(self, other): - # type: (Union[PrimitiveVector, Vector]) -> AABB + def __add__(self, other: Union[PrimitiveVector, Vector]) -> "AABB": other = Vector.from_other(other) return AABB(*self.top_left, *self.bot_right, self.position + other) - def __repr__(self): # pragma: no coverage + def __repr__(self) -> str: # pragma: no coverage return "({}, {}, {}, {}) at {}".format( self.top_left[0], self.top_left[1], @@ -255,7 +250,8 @@ def __repr__(self): # pragma: no coverage ) -PrimitiveAABB = "list[list[float, float], list[float, float]]" +# TODO: move this +PrimitiveAABB = tuple[PrimitiveVector, PrimitiveVector] class Rectangle(Shape): @@ -265,8 +261,13 @@ class Rectangle(Shape): ``position`` (it's center). """ - def __init__(self, position, width, height, angle): - # type: (Vector, float, float, float) -> None + def __init__( + self, + position: Union[Vector, PrimitiveVector], + width: float, + height: float, + angle: float, + ): """ Creates a :py:class:`.Rectangle`. Initializes it's :py:attr:`.points` attribute to specify @@ -294,12 +295,10 @@ def __init__(self, position, width, height, angle): edge = [p2[0] - p1[0], p2[1] - p1[1]] self.normals[i] = normalize(perpendicular(edge)) - def overlaps(self, other): - # type: (Shape) -> bool + def overlaps(self, other: "Shape") -> bool: return rect_overlaps_rect(self, other) - def get_points(self): - # type: () -> list[PrimitiveVector] + def get_points(self) -> list[PrimitiveVector]: """ Returns all 4 points associated with the corners of the Rectangle, used for determining collision. @@ -315,8 +314,7 @@ def get_points(self): for point in rot_points ] - def get_bounding_box(self): - # type: () -> AABB + def get_bounding_box(self) -> AABB: """ Returns the minimum-encompassing bounding box around this Rectangle. Used for broadphase collision-checking in :py:class:`.SpatialDataStructure`. @@ -342,8 +340,7 @@ def get_bounding_box(self): return AABB(x_min, y_min, x_max, y_max) - def rotate(self, amt): - # type: (int) -> Rectangle + def rotate(self, amt: int) -> "Rectangle": """ Rotates the :py:class:`.Rectangle` by increments of 45 degrees and returns a new transformed instance. @@ -362,8 +359,7 @@ def rotate(self, amt): self.angle + amt * 45, ) - def __eq__(self, other): - # type: (Rectangle) -> bool + def __eq__(self, other: "Rectangle") -> bool: return ( isinstance(other, Rectangle) and self.position == other.position @@ -372,7 +368,7 @@ def __eq__(self, other): and self.angle == other.angle ) - def __repr__(self): # pragma: no coverage + def __repr__(self) -> str: # pragma: no coverage return "({}, {}, {}, {})".format( self.position, self.width, self.height, self.angle ) @@ -383,8 +379,7 @@ def __repr__(self): # pragma: no coverage # ============================================================================= -def string_to_JSON(string): - # type: (str) -> dict +def string_to_JSON(string: str) -> dict: """ Decodes a Factorio Blueprint string to a readable JSON Dict. Follows the data format specification `here `_. @@ -404,8 +399,7 @@ def string_to_JSON(string): raise MalformedBlueprintStringError(e) -def JSON_to_string(JSON): - # type: (dict) -> str +def JSON_to_string(JSON: dict) -> str: """ Encodes a JSON dict to a Factorio-readable blueprint string. @@ -429,8 +423,7 @@ def JSON_to_string(JSON): ).decode("utf-8") -def encode_version(major, minor, patch=0, dev_ver=0): - # type: (int, int, int, int) -> int +def encode_version(major: int, minor: int, patch: int = 0, dev_ver: int = 0) -> int: """ Converts version components to version number. @@ -449,8 +442,7 @@ def encode_version(major, minor, patch=0, dev_ver=0): return (major << 48) | (minor << 32) | (patch << 16) | (dev_ver) -def decode_version(version_number): - # type: (int) -> tuple[int, int, int, int] +def decode_version(version_number: int) -> tuple[int, int, int, int]: """ Converts version number to version components. Decodes a 64 bit unsigned integer into 4 unsigned shorts and returns them @@ -469,8 +461,7 @@ def decode_version(version_number): return major, minor, patch, dev_ver -def version_string_to_tuple(version_string): - # type: (str) -> tuple +def version_string_to_tuple(version_string: str) -> tuple[int, ...]: """ Converts a version string to a tuple. @@ -487,8 +478,7 @@ def version_string_to_tuple(version_string): return tuple([int(elem) for elem in version_string.split(".")]) -def version_tuple_to_string(version_tuple): - # type: (tuple) -> str +def version_tuple_to_string(version_tuple: tuple[int, ...]) -> str: """ Converts a version tuple to a string. @@ -509,8 +499,7 @@ def version_tuple_to_string(version_tuple): # ============================================================================= -def distance(point1, point2): - # type: (PrimitiveVector, PrimitiveVector) -> float +def distance(point1: PrimitiveVector, point2: PrimitiveVector) -> float: """ Gets the Euclidean distance between two points. This is mostly just for Python 2 compatability. @@ -523,8 +512,9 @@ def distance(point1, point2): return math.sqrt((point1[0] - point2[0]) ** 2 + (point1[1] - point2[1]) ** 2) -def rotate_vector(a, angle): # TODO: change to rotate_point to be consistent - # type: (PrimitiveVector, float) -> PrimitiveVector +def rotate_vector( + a: PrimitiveVector, angle: float +) -> PrimitiveVector: # TODO: change to rotate_point to be consistent """ Rotate a given vector by ``angle`` radians around the origin. @@ -539,8 +529,7 @@ def rotate_vector(a, angle): # TODO: change to rotate_point to be consistent ] -def dot_product(a, b): - # type: (PrimitiveVector, PrimitiveVector) -> float +def dot_product(a: PrimitiveVector, b: PrimitiveVector) -> float: """ Gets the dot product between two 2D vectors. @@ -552,8 +541,7 @@ def dot_product(a, b): return a[0] * b[0] + a[1] * b[1] -def magnitude(a): - # type: (PrimitiveVector) -> float +def magnitude(a: PrimitiveVector) -> float: """ Gets the magnitude of a point. @@ -564,8 +552,7 @@ def magnitude(a): return math.sqrt(dot_product(a, a)) -def normalize(a): - # type: (PrimitiveVector) -> PrimitiveVector +def normalize(a: PrimitiveVector) -> PrimitiveVector: """ Normalizes a vector such that it's magnitude is equal to 1. @@ -577,8 +564,7 @@ def normalize(a): return [a[0] / mag, a[1] / mag] -def perpendicular(a): - # type: (PrimitiveVector) -> PrimitiveVector +def perpendicular(a: PrimitiveVector) -> PrimitiveVector: """ Returns a perpendicular 2D vector from another vector. Used to generate normal vectors for the Separating Axis Theorem. @@ -595,8 +581,7 @@ def perpendicular(a): # ============================================================================= -def point_in_aabb(p, a): - # type: (PrimitiveVector, AABB) -> bool +def point_in_aabb(p: PrimitiveVector, a: AABB) -> bool: """ Checks to see if a PrimitiveVector ``p`` is located inside AABB ``a``. @@ -610,8 +595,7 @@ def point_in_aabb(p, a): ) -def aabb_overlaps_aabb(a, b): - # type: (AABB, AABB) -> bool +def aabb_overlaps_aabb(a: AABB, b: AABB) -> bool: """ Checks to see if AABB ``a`` overlaps AABB ``b``. @@ -630,8 +614,7 @@ def aabb_overlaps_aabb(a, b): ) -def point_in_circle(p, r, c=(0, 0)): - # type: (PrimitiveVector, float, PrimitiveVector) -> bool +def point_in_circle(p: PrimitiveVector, r: float, c: PrimitiveVector = (0, 0)) -> bool: """ Checks to see if a point ``p`` lies within radius ``r`` centered around point ``c``. If ``c`` is not provided, the origin is assumed. @@ -647,8 +630,7 @@ def point_in_circle(p, r, c=(0, 0)): return dx * dx + dy * dy <= r * r -def aabb_overlaps_circle(a, r, c): - # type: (AABB, float, PrimitiveVector) -> bool +def aabb_overlaps_circle(a: AABB, r: float, c: PrimitiveVector) -> bool: """ Checks to see if an AABB ``a`` overlaps a circle with radius ``r`` at point ``c``. Algorithm pulled from ``_ @@ -685,7 +667,9 @@ def aabb_overlaps_circle(a, r, c): return corner_distance_sq <= r**2 -def flatten_points_on(points, axis, result): +def flatten_points_on( + points: list[PrimitiveVector], axis: PrimitiveVector, result: PrimitiveVector +): """ Maps points along a particular axis, and returns the smallest and largest extent along said axis. @@ -705,7 +689,11 @@ def flatten_points_on(points, axis, result): result[1] = maxpoint -def is_separating_axis(a_points, b_points, axis): +def is_separating_axis( + a_points: list[PrimitiveVector], + b_points: list[PrimitiveVector], + axis: PrimitiveVector, +): """ Checks to see if the points of two quads (when projected onto a face normal) have a space in between their encompassed ranges, returning True if there @@ -725,8 +713,7 @@ def is_separating_axis(a_points, b_points, axis): return False -def rect_overlaps_rect(a, b): - # type: (Rectangle, Rectangle) -> bool +def rect_overlaps_rect(a: Rectangle, b: Rectangle) -> bool: """ Checks to see whether or not two (rotatable) :py:class:`.Rectangles` intersect with each other. Sourced from: @@ -753,8 +740,7 @@ def rect_overlaps_rect(a, b): return True -def extend_aabb(a, b): - # type: (Union[AABB, None], Union[AABB, None]) -> Union[AABB, None] +def extend_aabb(a: Optional[AABB], b: Optional[AABB]) -> Optional[AABB]: """ Gets the minimum AABB that encompasses two other bounding boxes. Used to 'grow' the size of a bounding box to encompass both inputs. @@ -779,8 +765,7 @@ def extend_aabb(a, b): ) -def aabb_to_dimensions(aabb): - # type: (AABB) -> tuple[int, int] +def aabb_to_dimensions(aabb: AABB) -> tuple[int, int]: """ Gets the tile-dimensions of an AABB, or the minimum number of tiles across each axis that the box would have to take up. If the input `aabb` is None, @@ -794,8 +779,8 @@ def aabb_to_dimensions(aabb): if aabb is None: return (0, 0) - if not isinstance(aabb, AABB): - aabb = AABB(aabb[0][0], aabb[0][1], aabb[1][0], aabb[1][1]) + # if not isinstance(aabb, AABB): + # aabb = AABB(aabb[0][0], aabb[0][1], aabb[1][0], aabb[1][1]) x = int(math.ceil(aabb.bot_right[0] - aabb.top_left[0])) y = int(math.ceil(aabb.bot_right[1] - aabb.top_left[1])) @@ -877,7 +862,7 @@ def parse_energy(energy_string: str) -> int: else: digits_string = energy_string[:-1] - return int(digits_string) * multiplier + return round(int(digits_string) * multiplier) # def ignore_traceback(func): diff --git a/draftsman/warning.py b/draftsman/warning.py index fbcdfd9..3286d37 100644 --- a/draftsman/warning.py +++ b/draftsman/warning.py @@ -111,6 +111,22 @@ class ItemCapacityWarning(DraftsmanWarning): pass +class FuelLimitationWarning(DraftsmanWarning): + """ + TODO + """ + + pass + + +class FuelCapacityWarning(DraftsmanWarning): + """ + TODO + """ + + pass + + class ModuleLimitationWarning(DraftsmanWarning): """ Raised when the modules inside of an :py:class:`.Entity` conflict, either @@ -229,6 +245,14 @@ class UnknownEntityWarning(UnknownElementWarning): pass +class UnknownFluidWarning(UnknownElementWarning): + """ + TODO + """ + + pass + + class UnknownItemWarning(UnknownElementWarning): """ TODO diff --git a/examples/1bit_lcd_builder.py b/examples/1bit_lcd_builder.py index 0095886..f019a8e 100644 --- a/examples/1bit_lcd_builder.py +++ b/examples/1bit_lcd_builder.py @@ -27,19 +27,19 @@ def main(): font_height = 6 # TODO: ensure we're in a vanilla configuration - + # First, we need to create an indexing list so we know which numeric values - # correspond to + # correspond to lcd_index = [] # We want to exclude color signals as they'll pollute the LCD's encoding color_signals = { - "signal-red", - "signal-green", - "signal-blue", - "signal-yellow", - "signal-pink", - "signal-cyan", - "signal-white" + "signal-red", + "signal-green", + "signal-blue", + "signal-yellow", + "signal-pink", + "signal-cyan", + "signal-white", } lcd_index += [signal for signal in signals.virtual if signal not in color_signals] lcd_index += signals.item @@ -63,7 +63,7 @@ def main(): pix = font_image.load() for y in range(0, im_height, 7): for x in range(0, im_width, 6): - print(pix[x,y]) + print(pix[x, y]) encoded_font = [] for ascii_index in range(128): @@ -74,8 +74,8 @@ def main(): ascii_char = 0 for sx in range(font_width): for sy in range(font_height): - bit_set = pix[x+sx,y+sy] == (255, 255, 255, 255) - ascii_char |= (bit_set << i) + bit_set = pix[x + sx, y + sy] == (255, 255, 255, 255) + ascii_char |= bit_set << i i += 1 print(ascii_char) @@ -84,9 +84,9 @@ def main(): print(encoded_font[64]) print(blueprint.to_string()) - + pass if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/beacon_memory_builder.py b/examples/beacon_memory_builder.py index e5ac3b7..bb7df0b 100644 --- a/examples/beacon_memory_builder.py +++ b/examples/beacon_memory_builder.py @@ -29,6 +29,7 @@ needed for longer periods. """ + def main(): # Parse args @@ -37,5 +38,6 @@ def main(): pass + if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/examples/item_stack_signals.py b/examples/item_stack_signals.py index 783d08a..3b150fc 100644 --- a/examples/item_stack_signals.py +++ b/examples/item_stack_signals.py @@ -52,10 +52,9 @@ def main(): combinator.tile_position = (x, y) signal_index = 0 - # Add the last combinator if partially full - if len(combinator.signals) > 0: - combinator.id = "{}_{}".format(x, y) - blueprint.entities.append(combinator) + # Add the last combinator + combinator.id = "{}_{}".format(x, y) + blueprint.entities.append(combinator) # Add connections to each neighbour for cx in range(x): @@ -77,5 +76,5 @@ def main(): print(blueprint.to_string()) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no coverage main() diff --git a/examples/pumpjack_placer.py b/examples/pumpjack_placer.py index 52c8fd0..3978cb8 100644 --- a/examples/pumpjack_placer.py +++ b/examples/pumpjack_placer.py @@ -8,22 +8,24 @@ # TODO: speed this thing up, shouldn't take 10+ seconds -import warnings from draftsman.blueprintable import Blueprint +from draftsman.constants import ValidationMode from draftsman.warning import OverlappingObjectsWarning +import warnings def main(): - blueprint = Blueprint() + blueprint = Blueprint(validate_assignment=ValidationMode.NONE) blueprint.label = "Huge Pumpjacks" blueprint.set_icons("pumpjack") - # Do this unless you want your stdout flooded with warnings + # We intentionally create a blueprint which has overlapping entities, so we + # suppress this warning here warnings.simplefilter("ignore", OverlappingObjectsWarning) - dim = 64 - for y in range(dim): - for x in range(dim): + dimension = 64 + for y in range(dimension): + for x in range(dimension): blueprint.entities.append("pumpjack", position=[x, y]) # If you want to see all the OverlappingObjectsWarning, do this: diff --git a/examples/signal_index.py b/examples/signal_index.py index e7232b1..94eed57 100644 --- a/examples/signal_index.py +++ b/examples/signal_index.py @@ -151,5 +151,5 @@ def add_signals_to_mapping(signals): print(blueprint.to_string()) -if __name__ == "__main__": +if __name__ == "__main__": # pragma: no coverage main() diff --git a/examples/train_configuration_usage.py b/examples/train_configuration_usage.py index da23701..f642689 100644 --- a/examples/train_configuration_usage.py +++ b/examples/train_configuration_usage.py @@ -7,7 +7,6 @@ def main(): - # Import a rail oval with a train stop with name "A" and name "B" blueprint = Blueprint() # fmt: off diff --git a/pyproject.toml b/pyproject.toml index 7fd26b9..5d2684b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,6 @@ [build-system] requires = ["setuptools"] -build-backend = "setuptools.build_meta" \ No newline at end of file +build-backend = "setuptools.build_meta" + +[tool.mypy] +allow_redefinition = true \ No newline at end of file diff --git a/test/data/test_fluids.py b/test/data/test_fluids.py new file mode 100644 index 0000000..576dd13 --- /dev/null +++ b/test/data/test_fluids.py @@ -0,0 +1,25 @@ +# test_fluids.py + +from draftsman.data import fluids +from draftsman.error import InvalidFluidError + +import pytest + + +class TestFluidData: + def test_add_fluid(self): + fluids.add_fluid("custom-fluid") + assert fluids.raw["custom-fluid"] == { + "name": "custom-fluid", + "order": "", + "default_temperature": 25, + "type": "fluid", + } + + def test_get_temperature_range(self): + assert fluids.get_temperature_range("water") == (15, 100) + assert fluids.get_temperature_range("steam") == (15, 1000) + assert fluids.get_temperature_range("petroleum-gas") == (25, 25) + + with pytest.raises(InvalidFluidError): + fluids.get_temperature_range("incorrect") diff --git a/test/data/test_instruments.py b/test/data/test_instruments.py new file mode 100644 index 0000000..2d9587d --- /dev/null +++ b/test/data/test_instruments.py @@ -0,0 +1,62 @@ +# test_instruments + +from draftsman.data import instruments +from draftsman.entity import ProgrammableSpeaker +from draftsman.warning import UnknownNoteWarning + +import pytest + + +class TestInstrumentData: + def test_add_instrument(self): + # Unknown entity + with pytest.raises(TypeError): + instruments.add_instrument( + instrument_name="new-instrument", + notes=["A", "B", "C"], + entity_name="unknown", + ) + # Known entity, but not a programmable speaker + with pytest.raises(TypeError): + instruments.add_instrument( + instrument_name="new-instrument", + notes=["A", "B", "C"], + entity_name="wooden-chest", + ) + + instruments.add_instrument( + instrument_name="new-instrument", notes=["A", "B", "C"] + ) + assert instruments.raw["programmable-speaker"][-1] == { + "name": "new-instrument", + "notes": [{"name": "A"}, {"name": "B"}, {"name": "C"}], + } + assert len(instruments.raw["programmable-speaker"]) == 13 + assert instruments.index_of["programmable-speaker"]["new-instrument"] == { + "self": 12, + "A": 0, + "B": 1, + "C": 2, + } + assert instruments.name_of["programmable-speaker"][12] == { + "self": "new-instrument", + 0: "A", + 1: "B", + 2: "C", + } + + # Test setting an actual ProgrammableSpeaker with this new instrument + speaker = ProgrammableSpeaker("programmable-speaker") + + speaker.instrument_name = "new-instrument" + assert speaker.instrument_name == "new-instrument" + assert speaker.instrument_id == 12 + + speaker.note_name = "A" + assert speaker.note_name == "A" + assert speaker.note_id == 0 + + with pytest.warns(UnknownNoteWarning): + speaker.note_name = "D" + assert speaker.note_name == None + assert speaker.note_id == None diff --git a/test/data/test_items.py b/test/data/test_items.py new file mode 100644 index 0000000..99e09cf --- /dev/null +++ b/test/data/test_items.py @@ -0,0 +1,76 @@ +# test_items.py + +from draftsman.data import items + +import pytest + + +class TestItemsData: + def test_add_item(self): + # Add item group + items.add_group(name="new-item-group") + assert "new-item-group" in items.groups + assert items.groups["new-item-group"] == { + "type": "item-group", + "name": "new-item-group", + "order": "", + "subgroups": [], + } + + # Add item subgroup + items.add_subgroup(name="new-item-subgroup", group="new-item-group") + assert "new-item-subgroup" in items.subgroups + assert items.subgroups["new-item-subgroup"] == { + "type": "item-subgroup", + "name": "new-item-subgroup", + "order": "", + "items": [], + } + assert ( + items.subgroups["new-item-subgroup"] + in items.groups["new-item-group"]["subgroups"] + ) + assert ( + items.groups["new-item-group"]["subgroups"][0] + is items.subgroups["new-item-subgroup"] + ) + + with pytest.raises(TypeError): + items.add_subgroup(name="fail-new-item-subgroup", group="nonexistant") + + assert "nonexistant" not in items.groups + assert "fail-new-item-subgroup" not in items.subgroups + + # Add item + items.add_item(name="new-item", stack_size=100, subgroup="new-item-subgroup") + assert "new-item" in items.raw + assert items.raw["new-item"] == { + "type": "item", + "name": "new-item", + "stack_size": 100, + "order": "", + "subgroup": "new-item-subgroup", + } + assert items.raw["new-item"] in items.subgroups["new-item-subgroup"]["items"] + assert items.subgroups["new-item-subgroup"]["items"][0] is items.raw["new-item"] + + with pytest.raises(TypeError): + items.add_item(name="fail-new-item", stack_size=100, subgroup="nonexistant") + + assert "nonexistant" not in items.subgroups + assert "fail-new-item" not in items.raw + + def test_modify_existing_item(self): + pass + + def test_get_stack_size(self): + assert items.get_stack_size("artillery-shell") == 1 + assert items.get_stack_size("nuclear-fuel") == 1 + assert items.get_stack_size("rocket-fuel") == 10 + assert items.get_stack_size("iron-ore") == 50 + assert items.get_stack_size("iron-plate") == 100 + assert items.get_stack_size("electronic-circuit") == 200 + assert items.get_stack_size("space-science-pack") == 2000 + + # TODO: should this raise an error instead? + assert items.get_stack_size("unknown!") == None diff --git a/test/data/test_modules.py b/test/data/test_modules.py new file mode 100644 index 0000000..5735b50 --- /dev/null +++ b/test/data/test_modules.py @@ -0,0 +1,45 @@ +# test_modules.py + +from draftsman.data import modules + +import pytest + + +class TestModuleData: + def test_categories_sorted(self): + assert modules.categories["speed"] == [ + "speed-module", + "speed-module-2", + "speed-module-3", + ] + assert modules.categories["productivity"] == [ + "productivity-module", + "productivity-module-2", + "productivity-module-3", + ] + assert modules.categories["effectivity"] == [ + "effectivity-module", + "effectivity-module-2", + "effectivity-module-3", + ] + + def test_add_module(self): + with pytest.raises(TypeError): + modules.add_module("new-productivity-module", "unknown-category") + assert len(modules.categories) == 3 + + modules.add_module("new-productivity-module", "productivity") + assert modules.raw["new-productivity-module"] == { + "name": "new-productivity-module", + "category": "productivity", + "effect": {}, + "tier": 0, + } + + # Cleanup so we don't affect any of the other tests + del modules.raw["new-productivity-module"] + + def test_add_module_category(self): + modules.add_module_category("new-module-category") + assert len(modules.categories) == 4 + assert modules.categories["new-module-category"] == [] diff --git a/test/data/test_recipes.py b/test/data/test_recipes.py new file mode 100644 index 0000000..0f12b59 --- /dev/null +++ b/test/data/test_recipes.py @@ -0,0 +1,28 @@ +# test_recipes.py + +from draftsman.data import recipes + +import pytest + + +class TestRecipeData: + def test_add_recipe(self): + # TODO: complete + with pytest.raises(NotImplementedError): + recipes.add_recipe("new-recipe", [["iron-ore", 5]], "new-item") + + def test_get_recipe_ingredients(self): + # Normal, list-type + assert recipes.get_recipe_ingredients("wooden-chest") == {"wood"} + # Normal, dict-type + assert recipes.get_recipe_ingredients("plastic-bar") == { + "petroleum-gas", + "coal", + } + # Expensive, list-type + assert recipes.get_recipe_ingredients("iron-gear-wheel") == {"iron-plate"} + # Custom examples + recipes.raw["test-1"] = {"ingredients": [["iron-plate", 2]]} + assert recipes.get_recipe_ingredients("test-1") == {"iron-plate"} + recipes.raw["test-2"] = {"normal": {"ingredients": [{"name": "iron-plate"}]}} + assert recipes.get_recipe_ingredients("test-2") == {"iron-plate"} diff --git a/test/prototypes/test_accumulator.py b/test/prototypes/test_accumulator.py index c50232a..9befac6 100644 --- a/test/prototypes/test_accumulator.py +++ b/test/prototypes/test_accumulator.py @@ -1,6 +1,7 @@ # test_accumulator.py from draftsman.classes.vector import Vector +from draftsman.constants import ValidationMode from draftsman.entity import Accumulator, accumulators, Container from draftsman.error import DataFormatError from draftsman.signatures import SignalID @@ -49,8 +50,10 @@ def test_output_signal(self): accumulator = Accumulator() # String case accumulator.output_signal = "signal-D" - assert accumulator.output_signal == SignalID(**{"name": "signal-D", "type": "virtual"}) - + assert accumulator.output_signal == SignalID( + **{"name": "signal-D", "type": "virtual"} + ) + # Dict case accumulator2 = Accumulator() accumulator2.output_signal = accumulator.output_signal @@ -65,6 +68,17 @@ def test_output_signal(self): with pytest.raises(DataFormatError): accumulator.output_signal = {"incorrectly": "formatted"} + accumulator.validate_assignment = "none" + assert accumulator.validate_assignment == ValidationMode.NONE + + accumulator.output_signal = "incorrect" + assert accumulator.output_signal == "incorrect" + assert accumulator.to_dict() == { + "name": "accumulator", + "position": {"x": 1, "y": 1}, + "control_behavior": {"output_signal": "incorrect"}, + } + def test_mergable(self): accumulatorA = Accumulator("accumulator", tile_position=(0, 0)) accumulatorB = Accumulator("accumulator", tile_position=(0, 0)) @@ -94,7 +108,9 @@ def test_merge(self): assert accumulatorA.name == "accumulator" assert accumulatorA.tile_position == Vector(0, 0) assert accumulatorA.tile_position.to_dict() == {"x": 0, "y": 0} - assert accumulatorA.output_signal == SignalID(**{"name": "signal-A", "type": "virtual"}) + assert accumulatorA.output_signal == SignalID( + **{"name": "signal-A", "type": "virtual"} + ) def test_eq(self): accumulatorA = Accumulator("accumulator", tile_position=(0, 0)) @@ -102,7 +118,7 @@ def test_eq(self): assert accumulatorA == accumulatorB - accumulatorA.output_signal = "signal-B" # Make sure it's not default! + accumulatorA.output_signal = "signal-B" # Make sure it's not default! assert accumulatorA != accumulatorB @@ -113,3 +129,225 @@ def test_eq(self): # hashable assert isinstance(accumulatorA, Hashable) + + def test_json_schema(self): + assert Accumulator.json_schema() == { + "$defs": { + "CircuitConnectionPoint": { + "additionalProperties": True, + "properties": { + "circuit_id": { + "anyOf": [ + {"enum": [1, 2], "type": "integer"}, + {"type": "null"}, + ], + "default": None, + "title": "Circuit Id", + }, + "entity_id": { + "exclusiveMaximum": 18446744073709551616, + "minimum": 0, + "title": "Entity Id", + "type": "integer", + }, + }, + "required": ["entity_id"], + "title": "CircuitConnectionPoint", + "type": "object", + }, + "CircuitConnections": { + "additionalProperties": True, + "properties": { + "green": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/CircuitConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Green", + }, + "red": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/CircuitConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Red", + }, + }, + "title": "CircuitConnections", + "type": "object", + }, + "Connections": { + "additionalProperties": True, + "properties": { + "1": { + "anyOf": [ + {"$ref": "#/$defs/CircuitConnections"}, + {"type": "null"}, + ], + "default": {"green": None, "red": None}, + }, + "2": { + "anyOf": [ + {"$ref": "#/$defs/CircuitConnections"}, + {"type": "null"}, + ], + "default": {"green": None, "red": None}, + }, + "Cu0": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/WireConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Cu0", + }, + "Cu1": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/WireConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Cu1", + }, + }, + "title": "Connections", + "type": "object", + }, + "ControlBehavior": { + "additionalProperties": True, + "properties": { + "output_signal": { + "anyOf": [{"$ref": "#/$defs/SignalID"}, {"type": "null"}], + "default": {"name": "signal-A", "type": "virtual"}, + "description": "The output signal to broadcast this accumulators charge level as\n" + "to any connected circuit network. The output value is as a \n" + "percentage, where '0' is empty and '100' is full.", + } + }, + "title": "ControlBehavior", + "type": "object", + }, + "FloatPosition": { + "additionalProperties": True, + "properties": { + "x": {"title": "X", "type": "number"}, + "y": {"title": "Y", "type": "number"}, + }, + "required": ["x", "y"], + "title": "FloatPosition", + "type": "object", + }, + "SignalID": { + "additionalProperties": True, + "properties": { + "name": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Name of the signal. If omitted, the signal is treated as no signal and \n" + "removed on import/export cycle.", + "title": "Name", + }, + "type": { + "description": "Category of the signal.", + "enum": ["item", "fluid", "virtual"], + "title": "Type", + "type": "string", + }, + }, + "required": ["name", "type"], + "title": "SignalID", + "type": "object", + }, + "WireConnectionPoint": { + "additionalProperties": True, + "properties": { + "entity_id": { + "exclusiveMaximum": 18446744073709551616, + "minimum": 0, + "title": "Entity Id", + "type": "integer", + }, + "wire_id": { + "anyOf": [ + {"enum": [0, 1], "type": "integer"}, + {"type": "null"}, + ], + "default": None, + "title": "Wire Id", + }, + }, + "required": ["entity_id"], + "title": "WireConnectionPoint", + "type": "object", + }, + }, + "additionalProperties": True, + "properties": { + "connections": { + "anyOf": [{"$ref": "#/$defs/Connections"}, {"type": "null"}], + "default": { + "1": {"green": None, "red": None}, + "2": {"green": None, "red": None}, + "Cu0": None, + "Cu1": None, + }, + "description": "All circuit and copper wire connections that this entity has. Note\n" + "that copper wire connections in this field are exclusively for \n" + "power-switch connections; for power-pole to power-pole connections \n" + "see the 'neighbours' key.", + }, + "control_behavior": { + "anyOf": [{"$ref": "#/$defs/ControlBehavior"}, {"type": "null"}], + "default": { + "output_signal": {"name": "signal-A", "type": "virtual"} + }, + }, + "entity_number": { + "description": "The number of the entity in it's parent blueprint, 1-based. In\n" + "practice this is the index of the dictionary in the blueprint's \n" + "'entities' list, but this is not enforced.\n" + "\n" + "NOTE: The range of this number is described as a 64-bit unsigned int,\n" + "but due to limitations with Factorio's PropertyTree implementation,\n" + "values above 2^53 will suffer from floating-point precision error.\n" + "See here: https://forums.factorio.com/viewtopic.php?p=592165#p592165", + "exclusiveMaximum": 18446744073709551616, + "minimum": 0, + "title": "Entity Number", + "type": "integer", + }, + "name": { + "description": "The internal ID of the entity.", + "title": "Name", + "type": "string", + }, + "position": { + "allOf": [{"$ref": "#/$defs/FloatPosition"}], + "description": "The position of the entity, almost always measured from it's center. \n" + "Measured in Factorio tiles.", + }, + "tags": { + "anyOf": [{"type": "object"}, {"type": "null"}], + "default": {}, + "description": "Any other additional metadata associated with this blueprint entity. \n" + "Frequently used by mods.", + "title": "Tags", + }, + }, + "required": ["name", "position", "entity_number"], + "title": "Accumulator", + "type": "object", + } diff --git a/test/prototypes/test_arithmetic_combinator.py b/test/prototypes/test_arithmetic_combinator.py index 9d03314..ade0d0d 100644 --- a/test/prototypes/test_arithmetic_combinator.py +++ b/test/prototypes/test_arithmetic_combinator.py @@ -2,16 +2,19 @@ from draftsman.classes.blueprint import Blueprint from draftsman.classes.group import Group -from draftsman.constants import Direction +from draftsman.constants import Direction, ValidationMode from draftsman.entity import ArithmeticCombinator, arithmetic_combinators, Container from draftsman.error import ( - InvalidEntityError, - InvalidSignalError, DataFormatError, - DraftsmanError, ) from draftsman.signatures import SignalID -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning, PureVirtualDisallowedWarning, SignalConfigurationWarning, UnknownSignalWarning +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + PureVirtualDisallowedWarning, + SignalConfigurationWarning, + UnknownSignalWarning, +) from collections.abc import Hashable import sys @@ -101,15 +104,17 @@ def test_constructor_init(self): with pytest.warns(UnknownKeywordWarning): ArithmeticCombinator(control_behavior={"unused_key": "something"}) with pytest.warns(UnknownKeywordWarning): - ArithmeticCombinator(control_behavior={"arithmetic_conditions": {"unused_key": "something"}}) + ArithmeticCombinator( + control_behavior={"arithmetic_conditions": {"unused_key": "something"}} + ) with pytest.warns(UnknownEntityWarning): ArithmeticCombinator("this is not an arithmetic combinator") - # Errors + # Errors with pytest.raises(DataFormatError): ArithmeticCombinator(control_behavior="incorrect") - def test_flags(self): + def test_power_and_circuit_flags(self): for name in arithmetic_combinators: combinator = ArithmeticCombinator(name) assert combinator.power_connectable == False @@ -122,13 +127,15 @@ def test_set_first_operand(self): assert combinator.first_operand == None combinator.first_operand = 100 assert combinator.first_operand == 100 - + combinator.second_operand = 200 assert combinator.first_operand == 100 assert combinator.second_operand == 200 - + combinator.first_operand = "signal-A" - assert combinator.first_operand == SignalID(**{"name": "signal-A", "type": "virtual"}) + assert combinator.first_operand == SignalID( + **{"name": "signal-A", "type": "virtual"} + ) assert combinator.second_operand == 200 combinator.first_operand = None @@ -165,6 +172,23 @@ def test_set_first_operand(self): assert combinator.first_operand == SignalID(name="signal-A", type="virtual") assert combinator.output_signal == SignalID(name="signal-each", type="virtual") + combinator.remove_arithmetic_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.first_operand = 10 + assert combinator.first_operand == 10 + combinator.first_operand = "test" + assert combinator.first_operand == "test" + + combinator.control_behavior.arithmetic_conditions = None + assert combinator.first_operand == None + + # Setting properly works from arithmetic_conditions == None + combinator.first_operand = 10 + assert combinator.first_operand == 10 + def test_set_operation(self): combinator = ArithmeticCombinator("arithmetic-combinator") assert combinator.operation == "*" @@ -183,10 +207,30 @@ def test_set_operation(self): with pytest.raises(DataFormatError): combinator.operation = "incorrect" + combinator.remove_arithmetic_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.operation = "incorrect" + assert combinator.operation == "incorrect" + assert combinator.to_dict() == { + "name": "arithmetic-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": {"arithmetic_conditions": {"operation": "incorrect"}}, + } + + combinator.control_behavior.arithmetic_conditions = None + assert combinator.operation == None + + # Setting properly works from arithmetic_conditions == None + combinator.operation = ">" + assert combinator.operation == ">" + def test_set_second_operand(self): combinator = ArithmeticCombinator("arithmetic-combinator") assert combinator.second_operand == 0 - + combinator.second_operand = 100 assert combinator.second_operand == 100 assert combinator.control_behavior.arithmetic_conditions.second_constant == 100 @@ -197,8 +241,13 @@ def test_set_second_operand(self): assert combinator.control_behavior.arithmetic_conditions.first_constant == 200 combinator.second_operand = "signal-A" - assert combinator.second_operand == SignalID(**{"name": "signal-A", "type": "virtual"}) - assert combinator.control_behavior.arithmetic_conditions.second_signal == SignalID(**{"name": "signal-A", "type": "virtual"}) + assert combinator.second_operand == SignalID( + **{"name": "signal-A", "type": "virtual"} + ) + assert ( + combinator.control_behavior.arithmetic_conditions.second_signal + == SignalID(**{"name": "signal-A", "type": "virtual"}) + ) assert combinator.control_behavior.arithmetic_conditions.second_constant == None combinator.second_operand = None @@ -237,15 +286,36 @@ def test_set_second_operand(self): assert combinator.second_operand == SignalID(name="signal-A", type="virtual") assert combinator.output_signal == SignalID(name="signal-each", type="virtual") + combinator.remove_arithmetic_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.second_operand = 10 + assert combinator.second_operand == 10 + combinator.second_operand = "test" + assert combinator.second_operand == "test" + + combinator.control_behavior.arithmetic_conditions = None + assert combinator.second_operand == None + + # Setting properly works from arithmetic_conditions == None + combinator.second_operand = 10 + assert combinator.second_operand == 10 + def test_set_output_signal(self): combinator = ArithmeticCombinator("arithmetic-combinator") assert combinator.output_signal == None combinator.output_signal = "signal-A" - assert combinator.output_signal == SignalID(**{"name": "signal-A", "type": "virtual"}) + assert combinator.output_signal == SignalID( + **{"name": "signal-A", "type": "virtual"} + ) combinator.output_signal = {"name": "signal-B", "type": "virtual"} - assert combinator.output_signal == SignalID(**{"name": "signal-B", "type": "virtual"}) + assert combinator.output_signal == SignalID( + **{"name": "signal-B", "type": "virtual"} + ) combinator.output_signal = None assert combinator.output_signal == None @@ -276,57 +346,110 @@ def test_set_output_signal(self): with pytest.warns(SignalConfigurationWarning): combinator.second_operand = "signal-each" + combinator.remove_arithmetic_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.output_signal = "incorrect" + assert combinator.output_signal == "incorrect" + assert combinator.to_dict() == { + "name": "arithmetic-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": { + "arithmetic_conditions": {"output_signal": "incorrect"} + }, + } + + combinator.control_behavior.arithmetic_conditions = None + assert combinator.output_signal == None + + # Setting properly works from arithmetic_conditions == None + combinator.output_signal = ">" + assert combinator.output_signal == ">" + def test_set_arithmetic_conditions(self): combinator = ArithmeticCombinator("arithmetic-combinator") combinator.set_arithmetic_conditions("signal-A", "+", "iron-ore") - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": { - "first_signal": {"name": "signal-A", "type": "virtual"}, - "operation": "+", - "second_signal": {"name": "iron-ore", "type": "item"}, - } - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{ + "arithmetic_conditions": { + "first_signal": {"name": "signal-A", "type": "virtual"}, + "operation": "+", + "second_signal": {"name": "iron-ore", "type": "item"}, + } + } + ) + ) combinator.set_arithmetic_conditions("signal-A", "/", "copper-ore", "signal-B") - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": { - "first_signal": {"name": "signal-A", "type": "virtual"}, - "operation": "/", - "second_signal": {"name": "copper-ore", "type": "item"}, - "output_signal": {"name": "signal-B", "type": "virtual"}, - } - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{ + "arithmetic_conditions": { + "first_signal": {"name": "signal-A", "type": "virtual"}, + "operation": "/", + "second_signal": {"name": "copper-ore", "type": "item"}, + "output_signal": {"name": "signal-B", "type": "virtual"}, + } + } + ) + ) combinator.set_arithmetic_conditions(10, "and", 100, "signal-C") - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": { - "first_constant": 10, - "operation": "AND", - "second_constant": 100, - "output_signal": {"name": "signal-C", "type": "virtual"}, - } - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{ + "arithmetic_conditions": { + "first_constant": 10, + "operation": "AND", + "second_constant": 100, + "output_signal": {"name": "signal-C", "type": "virtual"}, + } + } + ) + ) combinator.set_arithmetic_conditions(10, "or", "signal-D", "signal-E") - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": { - "first_constant": 10, - "operation": "OR", - "second_signal": {"name": "signal-D", "type": "virtual"}, - "output_signal": {"name": "signal-E", "type": "virtual"}, - } - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{ + "arithmetic_conditions": { + "first_constant": 10, + "operation": "OR", + "second_signal": {"name": "signal-D", "type": "virtual"}, + "output_signal": {"name": "signal-E", "type": "virtual"}, + } + } + ) + ) combinator.set_arithmetic_conditions(10, "or", None) - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": {"first_constant": 10, "operation": "OR"} - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{"arithmetic_conditions": {"first_constant": 10, "operation": "OR"}} + ) + ) combinator.set_arithmetic_conditions(None, None, None, None) - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{"arithmetic_conditions": {}}) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{"arithmetic_conditions": {}} + ) + ) combinator.set_arithmetic_conditions(None) - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": {"operation": "*", "second_constant": 0} - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{"arithmetic_conditions": {"operation": "*", "second_constant": 0}} + ) + ) # TODO: change these from SchemaErrors with pytest.raises(DataFormatError): @@ -346,13 +469,18 @@ def test_set_arithmetic_conditions(self): "signal-A", "+", "signal-D", "incorrect" ) - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": {"operation": "*", "second_constant": 0} - }) + assert ( + combinator.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{"arithmetic_conditions": {"operation": "*", "second_constant": 0}} + ) + ) # Test Remove conditions combinator.remove_arithmetic_conditions() - assert combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior() + assert ( + combinator.control_behavior == ArithmeticCombinator.Format.ControlBehavior() + ) def test_mergable(self): combinatorA = ArithmeticCombinator("arithmetic-combinator") @@ -385,14 +513,19 @@ def test_merge(self): combinatorB.set_arithmetic_conditions("signal-A", "+", "signal-B", "signal-C") combinatorA.merge(combinatorB) - assert combinatorA.control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": { - "first_signal": {"name": "signal-A", "type": "virtual"}, - "operation": "+", - "second_signal": {"name": "signal-B", "type": "virtual"}, - "output_signal": {"name": "signal-C", "type": "virtual"}, - } - }) + assert ( + combinatorA.control_behavior + == ArithmeticCombinator.Format.ControlBehavior( + **{ + "arithmetic_conditions": { + "first_signal": {"name": "signal-A", "type": "virtual"}, + "operation": "+", + "second_signal": {"name": "signal-B", "type": "virtual"}, + "output_signal": {"name": "signal-C", "type": "virtual"}, + } + } + ) + ) # Test blueprint merging blueprint = Blueprint() @@ -406,14 +539,18 @@ def test_merge(self): blueprint.entities.append(entity_to_merge, merge=True) assert len(blueprint.entities) == 1 - assert blueprint.entities[0].control_behavior == ArithmeticCombinator.Format.ControlBehavior(**{ - "arithmetic_conditions": { - "first_signal": {"name": "signal-A", "type": "virtual"}, - "operation": "+", - "second_signal": {"name": "signal-B", "type": "virtual"}, - "output_signal": {"name": "signal-C", "type": "virtual"}, + assert blueprint.entities[ + 0 + ].control_behavior == ArithmeticCombinator.Format.ControlBehavior( + **{ + "arithmetic_conditions": { + "first_signal": {"name": "signal-A", "type": "virtual"}, + "operation": "+", + "second_signal": {"name": "signal-B", "type": "virtual"}, + "output_signal": {"name": "signal-C", "type": "virtual"}, + } } - }) + ) # Test dual-circuit-connections as well as self-reference group = Group() @@ -455,3 +592,310 @@ def test_eq(self): # hashable assert isinstance(combinatorA, Hashable) + + def test_json_schema(self): + assert ArithmeticCombinator.json_schema() == { + "$defs": { + "ArithmeticConditions": { + "additionalProperties": True, + "properties": { + "first_constant": { + "anyOf": [ + { + "exclusiveMaximum": 2147483648, + "minimum": -2147483648, + "type": "integer", + }, + {"type": "null"}, + ], + "default": None, + "description": "The constant value located in the left slot, if present.", + "title": "First Constant", + }, + "first_signal": { + "anyOf": [{"$ref": "#/$defs/SignalID"}, {"type": "null"}], + "default": None, + "description": "The signal type located in the left slot, if present. If\nboth this key and 'first_constant' are defined, this key\ntakes precedence.", + }, + "operation": { + "default": "*", + "description": "The operation to perform on the two operands.", + "enum": [ + "*", + "/", + "+", + "-", + "%", + "^", + "<<", + ">>", + "AND", + "OR", + "XOR", + None, + ], + "title": "Operation", + }, + "second_constant": { + "anyOf": [ + { + "exclusiveMaximum": 2147483648, + "minimum": -2147483648, + "type": "integer", + }, + {"type": "null"}, + ], + "default": 0, + "description": "The constant value located in the right slot, if present.", + "title": "Second Constant", + }, + "second_signal": { + "anyOf": [{"$ref": "#/$defs/SignalID"}, {"type": "null"}], + "default": None, + "description": "The signal type located in the right slot, if present. If\nboth this key and 'second_constant' are defined, this key\ntakes precedence.", + }, + "output_signal": { + "anyOf": [{"$ref": "#/$defs/SignalID"}, {"type": "null"}], + "default": None, + "description": "The output signal to emit the operation result as. Can be\n'signal-each', but only if one of 'first_signal' or \n'second_signal' is also 'signal-each'. No other pure virtual\nsignals are permitted in arithmetic combinators.", + }, + }, + "title": "ArithmeticConditions", + "type": "object", + }, + "CircuitConnectionPoint": { + "additionalProperties": True, + "properties": { + "entity_id": { + "exclusiveMaximum": 18446744073709551616, + "minimum": 0, + "title": "Entity Id", + "type": "integer", + }, + "circuit_id": { + "anyOf": [ + {"enum": [1, 2], "type": "integer"}, + {"type": "null"}, + ], + "default": None, + "title": "Circuit Id", + }, + }, + "required": ["entity_id"], + "title": "CircuitConnectionPoint", + "type": "object", + }, + "CircuitConnections": { + "additionalProperties": True, + "properties": { + "red": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/CircuitConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Red", + }, + "green": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/CircuitConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Green", + }, + }, + "title": "CircuitConnections", + "type": "object", + }, + "Connections": { + "additionalProperties": True, + "properties": { + "1": { + "anyOf": [ + {"$ref": "#/$defs/CircuitConnections"}, + {"type": "null"}, + ], + "default": {"green": None, "red": None}, + }, + "2": { + "anyOf": [ + {"$ref": "#/$defs/CircuitConnections"}, + {"type": "null"}, + ], + "default": {"green": None, "red": None}, + }, + "Cu0": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/WireConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Cu0", + }, + "Cu1": { + "anyOf": [ + { + "items": {"$ref": "#/$defs/WireConnectionPoint"}, + "type": "array", + }, + {"type": "null"}, + ], + "default": None, + "title": "Cu1", + }, + }, + "title": "Connections", + "type": "object", + }, + "ControlBehavior": { + "additionalProperties": True, + "properties": { + "arithmetic_conditions": { + "anyOf": [ + {"$ref": "#/$defs/ArithmeticConditions"}, + {"type": "null"}, + ], + "default": { + "first_constant": None, + "first_signal": None, + "operation": "*", + "output_signal": None, + "second_constant": 0, + "second_signal": None, + }, + } + }, + "title": "ControlBehavior", + "type": "object", + }, + "FloatPosition": { + "additionalProperties": True, + "properties": { + "x": {"title": "X", "type": "number"}, + "y": {"title": "Y", "type": "number"}, + }, + "required": ["x", "y"], + "title": "FloatPosition", + "type": "object", + }, + "SignalID": { + "additionalProperties": True, + "properties": { + "name": { + "anyOf": [{"type": "string"}, {"type": "null"}], + "description": "Name of the signal. If omitted, the signal is treated as no signal and \nremoved on import/export cycle.", + "title": "Name", + }, + "type": { + "description": "Category of the signal.", + "enum": ["item", "fluid", "virtual"], + "title": "Type", + "type": "string", + }, + }, + "required": ["name", "type"], + "title": "SignalID", + "type": "object", + }, + "WireConnectionPoint": { + "additionalProperties": True, + "properties": { + "entity_id": { + "exclusiveMaximum": 18446744073709551616, + "minimum": 0, + "title": "Entity Id", + "type": "integer", + }, + "wire_id": { + "anyOf": [ + {"enum": [0, 1], "type": "integer"}, + {"type": "null"}, + ], + "default": None, + "title": "Wire Id", + }, + }, + "required": ["entity_id"], + "title": "WireConnectionPoint", + "type": "object", + }, + "draftsman__constants__Direction__1": { + "$ref": "#/$defs/draftsman__constants__Direction__2" + }, + "draftsman__constants__Direction__2": { + "description": "Factorio direction enum. Encompasses all 8 cardinal directions and diagonals\nin the range [0, 7] where north is 0 and increments clockwise. Provides a\nnumber of convenience constants and functions over working with a raw int\nvalue.\n\n* ``NORTH`` (0) (Default)\n* ``NORTHEAST`` (1)\n* ``EAST`` (2)\n* ``SOUTHEAST`` (3)\n* ``SOUTH`` (4)\n* ``SOUTHWEST`` (5)\n* ``WEST`` (6)\n* ``NORTHWEST`` (7)", + "enum": [0, 1, 2, 3, 4, 5, 6, 7], + "title": "Direction", + "type": "integer", + }, + }, + "additionalProperties": True, + "properties": { + "name": { + "description": "The internal ID of the entity.", + "title": "Name", + "type": "string", + }, + "position": { + "allOf": [{"$ref": "#/$defs/FloatPosition"}], + "description": "The position of the entity, almost always measured from it's center. \nMeasured in Factorio tiles.", + }, + "entity_number": { + "description": "The number of the entity in it's parent blueprint, 1-based. In\npractice this is the index of the dictionary in the blueprint's \n'entities' list, but this is not enforced.\n\nNOTE: The range of this number is described as a 64-bit unsigned int,\nbut due to limitations with Factorio's PropertyTree implementation,\nvalues above 2^53 will suffer from floating-point precision error.\nSee here: https://forums.factorio.com/viewtopic.php?p=592165#p592165", + "exclusiveMaximum": 18446744073709551616, + "minimum": 0, + "title": "Entity Number", + "type": "integer", + }, + "tags": { + "anyOf": [{"type": "object"}, {"type": "null"}], + "default": {}, + "description": "Any other additional metadata associated with this blueprint entity. \nFrequently used by mods.", + "title": "Tags", + }, + "direction": { + "anyOf": [ + {"$ref": "#/$defs/draftsman__constants__Direction__1"}, + {"type": "null"}, + ], + "default": 0, + "description": "The grid-aligned direction this entity is facing. Direction can only\nbe one of 4 distinct (cardinal) directions, which differs from \n'orientation' which is used for RollingStock.", + }, + "connections": { + "anyOf": [{"$ref": "#/$defs/Connections"}, {"type": "null"}], + "default": { + "1": {"green": None, "red": None}, + "2": {"green": None, "red": None}, + "Cu0": None, + "Cu1": None, + }, + "description": "All circuit and copper wire connections that this entity has. Note\nthat copper wire connections in this field are exclusively for \npower-switch connections; for power-pole to power-pole connections \nsee the 'neighbours' key.", + }, + "control_behavior": { + "anyOf": [{"$ref": "#/$defs/ControlBehavior"}, {"type": "null"}], + "default": { + "arithmetic_conditions": { + "first_constant": None, + "first_signal": None, + "operation": "*", + "output_signal": None, + "second_constant": 0, + "second_signal": None, + } + }, + }, + }, + "required": ["name", "position", "entity_number"], + "title": "ArithmeticCombinator", + "type": "object", + } diff --git a/test/prototypes/test_assembling_machine.py b/test/prototypes/test_assembling_machine.py index 9166314..9739afe 100644 --- a/test/prototypes/test_assembling_machine.py +++ b/test/prototypes/test_assembling_machine.py @@ -2,12 +2,19 @@ from draftsman.constants import Direction from draftsman.entity import AssemblingMachine, assembling_machines, Container -from draftsman.error import InvalidEntityError, InvalidRecipeError, InvalidItemError, DataFormatError +from draftsman.error import ( + InvalidEntityError, + InvalidRecipeError, + InvalidItemError, + DataFormatError, +) from draftsman.warning import ( ModuleCapacityWarning, ModuleLimitationWarning, ItemLimitationWarning, + RecipeLimitationWarning, UnknownEntityWarning, + UnknownItemWarning, UnknownKeywordWarning, UnknownRecipeWarning, ) @@ -47,41 +54,41 @@ def test_constructor_init(self): def test_set_recipe(self): machine = AssemblingMachine("assembling-machine-3") assert machine.allowed_modules == { - 'speed-module', - 'speed-module-2', - 'speed-module-3', - 'effectivity-module', - 'effectivity-module-2', - 'effectivity-module-3', - 'productivity-module', - 'productivity-module-2', - 'productivity-module-3' + "speed-module", + "speed-module-2", + "speed-module-3", + "effectivity-module", + "effectivity-module-2", + "effectivity-module-3", + "productivity-module", + "productivity-module-2", + "productivity-module-3", } machine.set_item_request("productivity-module-3", 2) - + machine.recipe = "iron-gear-wheel" assert machine.recipe == "iron-gear-wheel" assert machine.allowed_modules == { - 'speed-module', - 'speed-module-2', - 'speed-module-3', - 'effectivity-module', - 'effectivity-module-2', - 'effectivity-module-3', - 'productivity-module', - 'productivity-module-2', - 'productivity-module-3' + "speed-module", + "speed-module-2", + "speed-module-3", + "effectivity-module", + "effectivity-module-2", + "effectivity-module-3", + "productivity-module", + "productivity-module-2", + "productivity-module-3", } with pytest.warns(ItemLimitationWarning): machine.recipe = "wooden-chest" assert machine.allowed_modules == { - 'speed-module', - 'speed-module-2', - 'speed-module-3', - 'effectivity-module', - 'effectivity-module-2', - 'effectivity-module-3', + "speed-module", + "speed-module-2", + "speed-module-3", + "effectivity-module", + "effectivity-module-2", + "effectivity-module-3", } machine.items = None @@ -90,15 +97,36 @@ def test_set_recipe(self): # with pytest.warns(ModuleLimitationWarning): # machine.recipe = "iron-chest" + # particular recipe not allowed in machine + with pytest.warns(RecipeLimitationWarning): + machine.recipe = "sulfur" + assert machine.recipe == "sulfur" + + # Unknown recipe in an unknown machine + machine = AssemblingMachine("unknown", validate="none") + with pytest.warns(UnknownRecipeWarning): + machine.recipe = "unknown" + + # Known recipe in an unknown machine + machine.recipe = "sulfur" + def test_set_item_request(self): machine = AssemblingMachine("assembling-machine-3") machine.recipe = "wooden-chest" with pytest.warns(ModuleLimitationWarning): machine.set_item_request("productivity-module-3", 2) - machine.items = None # TODO: should be able to remove this - machine.set_item_request("wood", 20) # because this ideally shouldn't raise a warning - assert machine.items == {"wood": 20} # {"productivity-module-3": 2, "wood": 20} + machine.items = None # TODO: should be able to remove this + machine.set_item_request( + "wood", 20 + ) # because this ideally shouldn't raise a warning + assert machine.items == {"wood": 20} # {"productivity-module-3": 2, "wood": 20} + + # No warning when we omit recipe + machine.recipe = None + assert machine.recipe == None + machine.items = {"productivity-module-3": 2, "productivity-module-2": 2} + assert machine.items == {"productivity-module-3": 2, "productivity-module-2": 2} machine.recipe = None machine.items = None @@ -115,17 +143,22 @@ def test_set_item_request(self): with pytest.warns(ItemLimitationWarning): machine.set_item_request("copper-cable", 100) + # Switching to the correct recipe raises no warnings as it fixes the issue machine.recipe = "electronic-circuit" # Errors - with pytest.raises(TypeError): + machine.items = None + with pytest.raises(DataFormatError): machine.set_item_request(None, "nonsense") - # with pytest.raises(InvalidItemError): # TODO - # machine.set_item_request("incorrect", 100) - with pytest.raises(TypeError): + with pytest.warns(UnknownItemWarning): + machine.set_item_request("unknown", 100) + with pytest.raises(DataFormatError): machine.set_item_request("speed-module-2", "nonsense") - # with pytest.raises(ValueError): # TODO - # machine.set_item_request("speed-module-2", -1) + with pytest.raises(DataFormatError): + machine.set_item_request("speed-module-2", -1) + + assert machine.items == {"unknown": 100} + assert machine.module_slots_occupied == 0 def test_mergable_with(self): machine1 = AssemblingMachine("assembling-machine-1") diff --git a/test/prototypes/test_beacon.py b/test/prototypes/test_beacon.py index d3200f2..b34c2d8 100644 --- a/test/prototypes/test_beacon.py +++ b/test/prototypes/test_beacon.py @@ -1,13 +1,14 @@ # test_beacon.py from draftsman.entity import Beacon, beacons, Container -from draftsman.error import InvalidEntityError, InvalidItemError +from draftsman.error import DataFormatError from draftsman.warning import ( ModuleCapacityWarning, ModuleNotAllowedWarning, ItemLimitationWarning, UnknownEntityWarning, - UnknownKeywordWarning + UnknownItemWarning, + UnknownKeywordWarning, ) from collections.abc import Hashable @@ -32,9 +33,16 @@ def test_set_item_request(self): assert beacon.total_module_slots == 2 with pytest.warns(ModuleCapacityWarning): beacon.set_item_request("effectivity-module-3", 3) - + beacon.items = None - assert beacon.allowed_modules == {"speed-module", "speed-module-2", "speed-module-3", "effectivity-module", "effectivity-module-2", "effectivity-module-3"} + assert beacon.allowed_modules == { + "speed-module", + "speed-module-2", + "speed-module-3", + "effectivity-module", + "effectivity-module-2", + "effectivity-module-3", + } with pytest.warns(ModuleNotAllowedWarning): beacon.set_item_request("productivity-module-3", 1) @@ -44,12 +52,17 @@ def test_set_item_request(self): # Errors beacon.items = None - with pytest.raises(TypeError): + with pytest.raises(DataFormatError): beacon.set_item_request("incorrect", "nonsense") - # with pytest.raises(InvalidItemError): # TODO - # beacon.set_item_request("incorrect", 100) - with pytest.raises(TypeError): + with pytest.warns(UnknownItemWarning): + beacon.set_item_request("unknown", 100) + with pytest.raises(DataFormatError): beacon.set_item_request("speed-module-2", "nonsense") + with pytest.raises(DataFormatError): + beacon.set_item_request("speed-module-2", -1) + + assert beacon.items == {"unknown": 100} + assert beacon.module_slots_occupied == 0 def test_mergable_with(self): beacon1 = Beacon("beacon") diff --git a/test/prototypes/test_burner_generator.py b/test/prototypes/test_burner_generator.py index 88e4255..a350017 100644 --- a/test/prototypes/test_burner_generator.py +++ b/test/prototypes/test_burner_generator.py @@ -1,8 +1,14 @@ # test_burner_generator.py +from draftsman.constants import ValidationMode from draftsman.entity import BurnerGenerator, burner_generators, Container from draftsman.error import InvalidEntityError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + FuelCapacityWarning, + ItemLimitationWarning, + UnknownEntityWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -18,6 +24,36 @@ def test_contstructor_init(self): with pytest.warns(UnknownEntityWarning): BurnerGenerator("this is not a burner generator") + def test_set_items(self): + generator = BurnerGenerator("burner-generator") + assert generator.allowed_fuel_items == { + "coal", + "solid-fuel", + "wood", + "nuclear-fuel", + "rocket-fuel", + } + + generator.set_item_request("coal", 50) + assert generator.items == {"coal": 50} + + with pytest.warns(ItemLimitationWarning): + generator.items = {"iron-plate": 1000} + assert generator.items == {"iron-plate": 1000} + + with pytest.warns(FuelCapacityWarning): + generator.set_item_request("coal", 200) + assert generator.items == {"coal": 200, "iron-plate": 1000} + + generator.validate_assignment = "minimum" + assert generator.validate_assignment == ValidationMode.MINIMUM + + generator.items = {"coal": 200, "iron-plate": 1000} + assert generator.items == {"coal": 200, "iron-plate": 1000} + + # Ensure that validating without a custom context doesn't break it + BurnerGenerator.Format.model_validate(generator._root) + def test_mergable_with(self): generator1 = BurnerGenerator("burner-generator") generator2 = BurnerGenerator("burner-generator", tags={"some": "stuff"}) diff --git a/test/prototypes/test_cargo_wagon.py b/test/prototypes/test_cargo_wagon.py index b09911e..4a85a40 100644 --- a/test/prototypes/test_cargo_wagon.py +++ b/test/prototypes/test_cargo_wagon.py @@ -1,12 +1,19 @@ # test_cargo_wagon.py +from draftsman.constants import Orientation, ValidationMode from draftsman.entity import CargoWagon, cargo_wagons, Container from draftsman.error import DataFormatError -from draftsman.signatures import Filters -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.signatures import FilterEntry +from draftsman.warning import ( + BarWarning, + UnknownEntityWarning, + UnknownItemWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest +import warnings class TestCargoWagon: @@ -84,6 +91,221 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): CargoWagon("cargo-wagon", inventory="incorrect") + def test_set_orientation(self): + wagon = CargoWagon("cargo-wagon") + assert wagon.orientation == Orientation.NORTH + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + } + + wagon.orientation = None + assert wagon.orientation == Orientation.NORTH + assert wagon.collision_set.shapes[0].angle == 0 + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + } + + # Unknown wagon + wagon = CargoWagon("unknown-cargo-wagon", validate=ValidationMode.MINIMUM) + wagon.orientation = None + assert wagon.orientation == Orientation.NORTH + assert wagon.collision_set is None + assert wagon.to_dict() == { + "name": "unknown-cargo-wagon", + "position": {"x": 0.0, "y": 0.0}, + } + + def test_set_inventory(self): + wagon = CargoWagon("cargo-wagon") + assert wagon.inventory == CargoWagon.Format.InventoryFilters() + + wagon.inventory = {"filters": None, "bar": 10} + assert wagon.inventory == CargoWagon.Format.InventoryFilters( + filters=None, bar=10 + ) + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + "inventory": {"bar": 10}, + } + + def test_set_filters(self): + wagon = CargoWagon("cargo-wagon") + assert wagon.filters == None + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + } + + # Shorthand format + wagon.filters = ["iron-ore", "copper-ore", "iron-ore"] + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "iron-ore"}), + FilterEntry(**{"index": 2, "name": "copper-ore"}), + FilterEntry(**{"index": 3, "name": "iron-ore"}), + ] + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + "inventory": { + "filters": [ + {"index": 1, "name": "iron-ore"}, + {"index": 2, "name": "copper-ore"}, + {"index": 3, "name": "iron-ore"}, + ] + }, + } + + # Explicit format + wagon.filters = [ + {"index": 1, "name": "iron-ore"}, + {"index": 2, "name": "copper-ore"}, + {"index": 3, "name": "iron-ore"}, + ] + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "iron-ore"}), + FilterEntry(**{"index": 2, "name": "copper-ore"}), + FilterEntry(**{"index": 3, "name": "iron-ore"}), + ] + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + "inventory": { + "filters": [ + {"index": 1, "name": "iron-ore"}, + {"index": 2, "name": "copper-ore"}, + {"index": 3, "name": "iron-ore"}, + ] + }, + } + + with pytest.warns(UnknownItemWarning): + wagon.filters = ["unknown"] + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "unknown"}), + ] + + with pytest.raises(DataFormatError): + wagon.filters = "incorrect" + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "unknown"}), + ] + + wagon.validate_assignment = "none" + assert wagon.validate_assignment == ValidationMode.NONE + + wagon.filters = "incorrect" + assert wagon.filters == "incorrect" + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + "inventory": {"filters": "incorrect"}, + } + + def test_set_inventory_filter(self): + wagon = CargoWagon("cargo-wagon") + + wagon.set_inventory_filter(0, "wooden-chest") + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "wooden-chest"}), + ] + + # Replace existing + wagon.set_inventory_filter(0, "iron-chest") + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "iron-chest"}), + ] + + # Remove existing + wagon.set_inventory_filter(0, None) + assert wagon.filters == [] + + # Ensure errors even if validation is off + wagon.validate_assignment = "none" + assert wagon.validate_assignment == ValidationMode.NONE + with pytest.raises(DataFormatError): + wagon.set_inventory_filter("incorrect", 0) + + def test_set_inventory_filters(self): + wagon = CargoWagon("cargo-wagon") + + # Shorthand + data = ["iron-ore", "copper-ore", "coal"] + wagon.set_inventory_filters(data) + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "iron-ore"}), + FilterEntry(**{"index": 2, "name": "copper-ore"}), + FilterEntry(**{"index": 3, "name": "coal"}), + ] + + # Longhand + data = [ + {"index": 1, "name": "iron-ore"}, + {"index": 2, "name": "copper-ore"}, + {"index": 3, "name": "coal"}, + ] + wagon.set_inventory_filters(data) + assert wagon.filters == [ + FilterEntry(**{"index": 1, "name": "iron-ore"}), + FilterEntry(**{"index": 2, "name": "copper-ore"}), + FilterEntry(**{"index": 3, "name": "coal"}), + ] + + wagon.set_inventory_filters(None) + assert wagon.filters == None + + # Ensure errors even if validation is off + wagon.validate_assignment = "none" + assert wagon.validate_assignment == ValidationMode.NONE + with pytest.raises(DataFormatError): + wagon.set_inventory_filters("incorrect") + + def test_set_inventory_bar(self): + wagon = CargoWagon("cargo-wagon") + assert wagon.bar == None + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + } + + wagon.bar = 10 + assert wagon.bar == 10 + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + "inventory": {"bar": 10}, + } + + wagon.bar = 100 + assert wagon.bar == 100 + + wagon.validate_assignment = "minimum" + assert wagon.validate_assignment == ValidationMode.MINIMUM + with warnings.catch_warnings(record=True) as w: + wagon.bar = 100 + assert len(w) == 0 + + wagon.validate_assignment = ValidationMode.PEDANTIC + with pytest.warns(BarWarning): + wagon.bar = 100 + + with pytest.raises(DataFormatError): + wagon.bar = "incorrect" + assert wagon.bar == 100 + + wagon.validate_assignment = "none" + assert wagon.validate_assignment == ValidationMode.NONE + + wagon.bar = "incorrect" + assert wagon.bar == "incorrect" + assert wagon.to_dict() == { + "name": "cargo-wagon", + "position": {"x": 1.0, "y": 2.5}, + "inventory": {"bar": "incorrect"}, + } + def test_mergable_with(self): wagon1 = CargoWagon("cargo-wagon") wagon2 = CargoWagon( @@ -115,7 +337,9 @@ def test_merge(self): assert wagon1.tags == {"some": "stuff"} assert wagon1.bar == 1 - assert wagon1.inventory["filters"] == Filters(root=[{"index": 1, "name": "transport-belt"}]) + assert wagon1.inventory["filters"] == [ + FilterEntry(**{"index": 1, "name": "transport-belt"}) + ] def test_eq(self): generator1 = CargoWagon("cargo-wagon") diff --git a/test/prototypes/test_constant_combinator.py b/test/prototypes/test_constant_combinator.py index 469bc12..eacd6d5 100644 --- a/test/prototypes/test_constant_combinator.py +++ b/test/prototypes/test_constant_combinator.py @@ -1,10 +1,14 @@ # test_constant_combinator.py -from draftsman.constants import Direction +from draftsman.constants import Direction, ValidationMode from draftsman.entity import ConstantCombinator, constant_combinators, Container from draftsman.error import DataFormatError from draftsman.signatures import SignalFilter -from draftsman.warning import PureVirtualDisallowedWarning, UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + PureVirtualDisallowedWarning, + UnknownEntityWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -98,7 +102,7 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): ConstantCombinator(control_behavior="incorrect") - def test_flags(self): + def test_power_and_circuit_flags(self): for name in constant_combinators: combinator = ConstantCombinator(name) assert combinator.power_connectable == False @@ -114,79 +118,72 @@ def test_set_signal(self): combinator = ConstantCombinator() combinator.set_signal(0, "signal-A", 100) assert combinator.signals == [ - SignalFilter( - index=1, - signal="signal-A", - count=100 - ) + SignalFilter(index=1, signal="signal-A", count=100) ] - + combinator.set_signal(1, "signal-B", 200) assert combinator.signals == [ - SignalFilter( - index=1, - signal="signal-A", - count=100 - ), - SignalFilter( - index=2, - signal="signal-B", - count=200 - ) + SignalFilter(index=1, signal="signal-A", count=100), + SignalFilter(index=2, signal="signal-B", count=200), ] combinator.set_signal(0, "signal-C", 300) assert combinator.signals == [ - SignalFilter( - index=1, - signal="signal-C", - count=300 - ), - SignalFilter( - index=2, - signal="signal-B", - count=200 - ) + SignalFilter(index=1, signal="signal-C", count=300), + SignalFilter(index=2, signal="signal-B", count=200), ] combinator.set_signal(1, None) assert combinator.signals == [ - SignalFilter( - index=1, - signal="signal-C", - count=300 - ) + SignalFilter(index=1, signal="signal-C", count=300) ] - with pytest.raises(TypeError): # TODO: FIXME + with pytest.raises(DataFormatError): combinator.set_signal(TypeError, "something") with pytest.raises(DataFormatError): combinator.set_signal(1, TypeError) with pytest.raises(DataFormatError): combinator.set_signal(1, "iron-ore", TypeError) - # with pytest.raises(DataFormatError): # TODO + # with pytest.raises(DataFormatError): # TODO: is this an error? # combinator.set_signal(-1, "iron-ore", 0) + assert combinator.item_slot_count == 20 + with pytest.raises(DataFormatError): + combinator.set_signal(100, "iron-ore", 1000) + + combinator = ConstantCombinator("unknown-combinator", validate="none") + assert combinator.item_slot_count == None + combinator.set_signal(100, "iron-ore", 1000) + assert combinator.signals == [ + SignalFilter(index=101, signal="iron-ore", count=1000) + ] + def test_set_signals(self): combinator = ConstantCombinator() # Test user format combinator.signals = [("signal-A", 100), ("signal-Z", 200), ("iron-ore", 1000)] assert combinator.signals == [ - SignalFilter(**{ - "index": 1, - "signal": {"name": "signal-A", "type": "virtual"}, - "count": 100, - }), - SignalFilter(**{ - "index": 2, - "signal": {"name": "signal-Z", "type": "virtual"}, - "count": 200, - }), - SignalFilter(**{ - "index": 3, - "signal": {"name": "iron-ore", "type": "item"}, - "count": 1000, - }), + SignalFilter( + **{ + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"}, + "count": 100, + } + ), + SignalFilter( + **{ + "index": 2, + "signal": {"name": "signal-Z", "type": "virtual"}, + "count": 200, + } + ), + SignalFilter( + **{ + "index": 3, + "signal": {"name": "iron-ore", "type": "item"}, + "count": 1000, + } + ), ] # Test internal format @@ -204,21 +201,27 @@ def test_set_signals(self): }, ] assert combinator.signals == [ - SignalFilter(**{ - "index": 1, - "signal": {"name": "signal-A", "type": "virtual"}, - "count": 100, - }), - SignalFilter(**{ - "index": 2, - "signal": {"name": "signal-Z", "type": "virtual"}, - "count": 200, - }), - SignalFilter(**{ - "index": 3, - "signal": {"name": "iron-ore", "type": "item"}, - "count": 1000, - }), + SignalFilter( + **{ + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"}, + "count": 100, + } + ), + SignalFilter( + **{ + "index": 2, + "signal": {"name": "signal-Z", "type": "virtual"}, + "count": 200, + } + ), + SignalFilter( + **{ + "index": 3, + "signal": {"name": "iron-ore", "type": "item"}, + "count": 1000, + } + ), ] # Test clear signals @@ -236,6 +239,17 @@ def test_set_signals(self): with pytest.raises(DataFormatError): combinator.signals = {"something", "wrong"} + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.signals = {"something", "wrong"} + assert combinator.signals == {"something", "wrong"} + assert combinator.to_dict() == { + "name": "constant-combinator", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"filters": {"something", "wrong"}}, + } + def test_get_signal(self): combinator = ConstantCombinator() signal = combinator.get_signal(0) @@ -244,11 +258,13 @@ def test_get_signal(self): combinator.signals = [("signal-A", 100), ("signal-Z", 200), ("iron-ore", 1000)] print(combinator.signals) signal = combinator.get_signal(0) - assert signal == SignalFilter(**{ - "index": 1, - "signal": {"name": "signal-A", "type": "virtual"}, - "count": 100, - }) + assert signal == SignalFilter( + **{ + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"}, + "count": 100, + } + ) signal = combinator.get_signal(50) assert signal == None @@ -292,6 +308,19 @@ def test_is_on(self): assert combinator.direction == Direction.WEST assert combinator.is_on == False + combinator = ConstantCombinator("constant-combinator") + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.is_on = "incorrect" + assert combinator.is_on == "incorrect" + assert combinator.to_dict() == { + "name": "constant-combinator", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"is_on": "incorrect"}, + } + def test_mergable_with(self): comb1 = ConstantCombinator("constant-combinator") comb2 = ConstantCombinator( @@ -334,15 +363,17 @@ def test_merge(self): comb1.merge(comb2) del comb2 - assert comb1.control_behavior == ConstantCombinator.Format.ControlBehavior(**{ - "filters": [ - { - "index": 1, - "signal": {"name": "signal-A", "type": "virtual"}, - "count": 100, - } - ] - }) + assert comb1.control_behavior == ConstantCombinator.Format.ControlBehavior( + **{ + "filters": [ + { + "index": 1, + "signal": {"name": "signal-A", "type": "virtual"}, + "count": 100, + } + ] + } + ) def test_eq(self): generator1 = ConstantCombinator("constant-combinator") diff --git a/test/prototypes/test_container.py b/test/prototypes/test_container.py index 956330e..4457cee 100644 --- a/test/prototypes/test_container.py +++ b/test/prototypes/test_container.py @@ -1,13 +1,17 @@ # test_container.py +from draftsman.constants import ValidationMode from draftsman.entity import Container, containers, Accumulator from draftsman.error import ( - DraftsmanError, - InvalidEntityError, DataFormatError, - InvalidItemError, ) -from draftsman.warning import BarWarning, ItemCapacityWarning, UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + BarWarning, + ItemCapacityWarning, + UnknownEntityWarning, + UnknownItemWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -78,11 +82,34 @@ def test_power_and_circuit_flags(self): assert container.circuit_connectable == True assert container.dual_circuit_connectable == False - def test_bar_with_disabled_containers(self): + def test_bar(self): + container = Container("wooden-chest") + + # No warning, because it's pedantic level + container.bar = 100 + assert container.bar == 100 + + container.validate_assignment = "pedantic" + assert container.validate_assignment == ValidationMode.PEDANTIC + + container.bar = 10 + assert container.bar == 10 + + with pytest.warns(BarWarning): + container.bar = 100 + assert container.bar == 100 + + # Disabled bar container = Container("big-ship-wreck-1") with pytest.warns(BarWarning): container.bar = 2 + container.validate_assignment = "none" + assert container.validate_assignment == ValidationMode.NONE + + container.bar = 2 + assert container.bar == 2 + def test_set_item_request(self): container = Container("wooden-chest") @@ -105,18 +132,22 @@ def test_set_item_request(self): assert container.items == {} assert container.inventory_slots_occupied == 0 - with pytest.raises(TypeError): + with pytest.raises(DataFormatError): container.set_item_request(TypeError, 100) - # with pytest.raises(InvalidItemError): # TODO - # container.set_item_request("incorrect", 100) - with pytest.raises(TypeError): + with pytest.warns(UnknownItemWarning): + container.set_item_request("unknown", 100) + with pytest.raises(DataFormatError): container.set_item_request("iron-plate", TypeError) - # with pytest.raises(ValueError): # TODO - # container.set_item_request("iron-plate", -1) + with pytest.raises(DataFormatError): + container.set_item_request("iron-plate", -1) - assert container.items == {} + assert container.items == {"unknown": 100} assert container.inventory_slots_occupied == 0 + with pytest.raises(DataFormatError): + container.items = {"incorrect", "format"} + assert container.items == {"unknown": 100} + def test_mergable_with(self): container1 = Container("wooden-chest") container2 = Container("wooden-chest", bar=10, items={"copper-plate": 100}) diff --git a/test/prototypes/test_curved_rail.py b/test/prototypes/test_curved_rail.py index 3111661..4fd8b96 100644 --- a/test/prototypes/test_curved_rail.py +++ b/test/prototypes/test_curved_rail.py @@ -3,7 +3,11 @@ from draftsman.constants import Direction from draftsman.entity import CurvedRail, curved_rails, Container from draftsman.error import InvalidEntityError -from draftsman.warning import GridAlignmentWarning, UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + GridAlignmentWarning, + UnknownEntityWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -32,6 +36,12 @@ def test_constructor_init(self): with pytest.warns(UnknownEntityWarning): CurvedRail("this is not a curved rail") + def test_flags(self): + rail = CurvedRail("curved-rail") + assert rail.rotatable == True + assert rail.square == False + assert rail.double_grid_aligned == True + def test_mergable_with(self): rail1 = CurvedRail("curved-rail") rail2 = CurvedRail("curved-rail", tags={"some": "stuff"}) diff --git a/test/prototypes/test_decider_combinator.py b/test/prototypes/test_decider_combinator.py index f6f0eb8..ab2052e 100644 --- a/test/prototypes/test_decider_combinator.py +++ b/test/prototypes/test_decider_combinator.py @@ -1,6 +1,6 @@ # test_arithmetic_combinator.py -from draftsman.constants import Direction +from draftsman.constants import Direction, ValidationMode from draftsman.entity import DeciderCombinator, decider_combinators, Container from draftsman.error import ( InvalidEntityError, @@ -8,7 +8,11 @@ DraftsmanError, ) from draftsman.signatures import SignalID -from draftsman.warning import PureVirtualDisallowedWarning, UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + PureVirtualDisallowedWarning, + UnknownEntityWarning, + UnknownKeywordWarning, +) from draftsman.data import signals @@ -81,7 +85,7 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): DeciderCombinator(control_behavior="incorrect") - def test_flags(self): + def test_power_and_circuit_flags(self): for name in decider_combinators: combinator = DeciderCombinator(name) assert combinator.power_connectable == False @@ -116,7 +120,22 @@ def test_set_first_operand(self): # We no longer set the output signal to none in the case of an invalid # configuration assert combinator.first_operand == SignalID(name="signal-each", type="virtual") - assert combinator.output_signal == SignalID(name="signal-everything", type="virtual") + assert combinator.output_signal == SignalID( + name="signal-everything", type="virtual" + ) + + combinator.remove_decider_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.first_operand = "incorrect" + assert combinator.first_operand == "incorrect" + assert combinator.to_dict() == { + "name": "decider-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": {"decider_conditions": {"first_signal": "incorrect"}}, + } def test_set_operation(self): combinator = DeciderCombinator("decider-combinator") @@ -136,6 +155,19 @@ def test_set_operation(self): with pytest.raises(DataFormatError): combinator.operation = "incorrect" + combinator.remove_decider_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.operation = "incorrect" + assert combinator.operation == "incorrect" + assert combinator.to_dict() == { + "name": "decider-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": {"decider_conditions": {"comparator": "incorrect"}}, + } + def test_set_second_operand(self): combinator = DeciderCombinator("decider-combinator") assert combinator.second_operand == 0 @@ -162,6 +194,31 @@ def test_set_second_operand(self): with pytest.warns(PureVirtualDisallowedWarning): combinator.second_operand = pure_virtual_signal + combinator.control_behavior.decider_conditions = None + assert combinator.control_behavior.decider_conditions == None + assert combinator.second_operand == None + + combinator.remove_decider_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.second_operand = 100.0 + assert combinator.second_operand == 100.0 + assert combinator.to_dict() == { + "name": "decider-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": {"decider_conditions": {"constant": 100.0}}, + } + + combinator.second_operand = "incorrect" + assert combinator.second_operand == "incorrect" + assert combinator.to_dict() == { + "name": "decider-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": {"decider_conditions": {"second_signal": "incorrect"}}, + } + def test_set_output_signal(self): combinator = DeciderCombinator("decider-combinator") assert combinator.output_signal == None @@ -181,11 +238,15 @@ def test_set_output_signal(self): combinator.remove_decider_conditions() combinator.output_signal = "signal-everything" - assert combinator.output_signal == SignalID(name="signal-everything", type="virtual") - + assert combinator.output_signal == SignalID( + name="signal-everything", type="virtual" + ) + with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-anything" - assert combinator.output_signal == SignalID(name="signal-anything", type="virtual") + assert combinator.output_signal == SignalID( + name="signal-anything", type="virtual" + ) with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-each" assert combinator.output_signal == SignalID(name="signal-each", type="virtual") @@ -193,12 +254,18 @@ def test_set_output_signal(self): combinator.remove_decider_conditions() combinator.first_operand = "signal-everything" combinator.output_signal = "signal-everything" - assert combinator.first_operand == SignalID(name="signal-everything", type="virtual") - assert combinator.output_signal == SignalID(name="signal-everything", type="virtual") + assert combinator.first_operand == SignalID( + name="signal-everything", type="virtual" + ) + assert combinator.output_signal == SignalID( + name="signal-everything", type="virtual" + ) with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-anything" - assert combinator.output_signal == SignalID(name="signal-anything", type="virtual") + assert combinator.output_signal == SignalID( + name="signal-anything", type="virtual" + ) with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-each" assert combinator.output_signal == SignalID(name="signal-each", type="virtual") @@ -206,12 +273,20 @@ def test_set_output_signal(self): combinator.remove_decider_conditions() combinator.first_operand = "signal-anything" combinator.output_signal = "signal-everything" - assert combinator.first_operand == SignalID(name="signal-anything", type="virtual") - assert combinator.output_signal == SignalID(name="signal-everything", type="virtual") + assert combinator.first_operand == SignalID( + name="signal-anything", type="virtual" + ) + assert combinator.output_signal == SignalID( + name="signal-everything", type="virtual" + ) combinator.output_signal = "signal-anything" - assert combinator.first_operand == SignalID(name="signal-anything", type="virtual") - assert combinator.output_signal == SignalID(name="signal-anything", type="virtual") + assert combinator.first_operand == SignalID( + name="signal-anything", type="virtual" + ) + assert combinator.output_signal == SignalID( + name="signal-anything", type="virtual" + ) with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-each" @@ -225,10 +300,27 @@ def test_set_output_signal(self): with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-everything" - assert combinator.output_signal == SignalID(name="signal-everything", type="virtual") + assert combinator.output_signal == SignalID( + name="signal-everything", type="virtual" + ) with pytest.warns(PureVirtualDisallowedWarning): combinator.output_signal = "signal-anything" - assert combinator.output_signal == SignalID(name="signal-anything", type="virtual") + assert combinator.output_signal == SignalID( + name="signal-anything", type="virtual" + ) + + combinator.remove_decider_conditions() + + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.output_signal = "incorrect" + assert combinator.output_signal == "incorrect" + assert combinator.to_dict() == { + "name": "decider-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": {"decider_conditions": {"output_signal": "incorrect"}}, + } def test_set_copy_count_from_input(self): combinator = DeciderCombinator() @@ -244,48 +336,67 @@ def test_set_copy_count_from_input(self): with pytest.raises(DataFormatError): combinator.copy_count_from_input = "incorrect" + combinator.validate_assignment = "none" + assert combinator.validate_assignment == ValidationMode.NONE + + combinator.copy_count_from_input = "incorrect" + assert combinator.copy_count_from_input == "incorrect" + assert combinator.to_dict() == { + "name": "decider-combinator", + "position": {"x": 0.5, "y": 1.0}, + "control_behavior": { + "decider_conditions": {"copy_count_from_input": "incorrect"} + }, + } + def test_set_decider_conditions(self): combinator = DeciderCombinator() combinator.set_decider_conditions("signal-A", ">", "iron-ore") - assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": { - "first_signal": {"name": "signal-A", "type": "virtual"}, - "comparator": ">", - "second_signal": {"name": "iron-ore", "type": "item"}, + assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{ + "decider_conditions": { + "first_signal": {"name": "signal-A", "type": "virtual"}, + "comparator": ">", + "second_signal": {"name": "iron-ore", "type": "item"}, + } } - }) + ) combinator.set_decider_conditions("signal-A", "=", "copper-ore", "signal-B") - assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": { - "first_signal": {"name": "signal-A", "type": "virtual"}, - "comparator": "=", - "second_signal": {"name": "copper-ore", "type": "item"}, - "output_signal": {"name": "signal-B", "type": "virtual"}, + assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{ + "decider_conditions": { + "first_signal": {"name": "signal-A", "type": "virtual"}, + "comparator": "=", + "second_signal": {"name": "copper-ore", "type": "item"}, + "output_signal": {"name": "signal-B", "type": "virtual"}, + } } - }) + ) combinator.set_decider_conditions("signal-D", "<", 10, "signal-E") - assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": { - "first_signal": {"name": "signal-D", "type": "virtual"}, - "comparator": "<", - "constant": 10, - "output_signal": {"name": "signal-E", "type": "virtual"}, + assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{ + "decider_conditions": { + "first_signal": {"name": "signal-D", "type": "virtual"}, + "comparator": "<", + "constant": 10, + "output_signal": {"name": "signal-E", "type": "virtual"}, + } } - }) + ) combinator.set_decider_conditions(None, ">", 10) - assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": {"constant": 10, "comparator": ">"} - }) + assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{"decider_conditions": {"constant": 10, "comparator": ">"}} + ) # combinator.set_decider_conditions(None, None, None, None) # assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{"decider_conditions": {}}) combinator.set_decider_conditions() - assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": {"comparator": "<", "constant": 0} - }) + assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{"decider_conditions": {"comparator": "<", "constant": 0}} + ) with pytest.raises(DataFormatError): combinator.set_decider_conditions(TypeError) @@ -303,9 +414,9 @@ def test_set_decider_conditions(self): combinator.set_decider_conditions("signal-A", "<", "signal-D", "incorrect") # TODO: - assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": {"comparator": "<", "constant": 0} - }) + assert combinator.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{"decider_conditions": {"comparator": "<", "constant": 0}} + ) # Test Remove conditions combinator.control_behavior = None @@ -369,15 +480,17 @@ def test_merge(self): comb1.merge(comb2) del comb2 - assert comb1.control_behavior == DeciderCombinator.Format.ControlBehavior(**{ - "decider_conditions": { - "first_signal": {"name": "signal-D", "type": "virtual"}, - "comparator": "<", - "constant": 10, - "output_signal": {"name": "signal-E", "type": "virtual"}, - "copy_count_from_input": False, + assert comb1.control_behavior == DeciderCombinator.Format.ControlBehavior( + **{ + "decider_conditions": { + "first_signal": {"name": "signal-D", "type": "virtual"}, + "comparator": "<", + "constant": 10, + "output_signal": {"name": "signal-E", "type": "virtual"}, + "copy_count_from_input": False, + } } - }) + ) assert comb1.tags == {} # Overwritten by comb2 def test_eq(self): diff --git a/test/prototypes/test_electric_energy_interface.py b/test/prototypes/test_electric_energy_interface.py index 1f10f3c..f6d176d 100644 --- a/test/prototypes/test_electric_energy_interface.py +++ b/test/prototypes/test_electric_energy_interface.py @@ -18,14 +18,22 @@ def test_constructor_init(self): "electric-energy-interface", buffer_size=10000, power_production=10000, - power_usage=0, + power_usage=100, ) assert interface.to_dict() == { "name": "electric-energy-interface", "position": {"x": 1.0, "y": 1.0}, "buffer_size": 10000, "power_production": 10000, - # "power_usage": 0, # Default + "power_usage": 100, + } + assert interface.to_dict(exclude_defaults=False) == { + "name": "electric-energy-interface", + "position": {"x": 1.0, "y": 1.0}, + "buffer_size": 10000, + "power_production": 10000, + "power_usage": 100, + "tags": {}, } with pytest.warns(UnknownKeywordWarning): diff --git a/test/prototypes/test_electric_pole.py b/test/prototypes/test_electric_pole.py index a427af1..c2df091 100644 --- a/test/prototypes/test_electric_pole.py +++ b/test/prototypes/test_electric_pole.py @@ -25,6 +25,13 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): ElectricPole(neighbours="incorrect") + def test_neighbours(self): + electric_pole = ElectricPole("small-electric-pole") + assert electric_pole.neighbours == [] + + electric_pole.neighbours = None + assert electric_pole.neighbours == [] + def test_mergable_with(self): group = Group() group.entities.append("small-electric-pole") diff --git a/test/prototypes/test_filter_inserter.py b/test/prototypes/test_filter_inserter.py index 4505604..8081de7 100644 --- a/test/prototypes/test_filter_inserter.py +++ b/test/prototypes/test_filter_inserter.py @@ -3,8 +3,12 @@ from draftsman.constants import Direction, ReadMode from draftsman.entity import FilterInserter, filter_inserters, Container from draftsman.error import DataFormatError, InvalidItemError -from draftsman.signatures import Filters -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.signatures import FilterEntry +from draftsman.warning import ( + UnknownEntityWarning, + UnknownItemWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -101,9 +105,7 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): FilterInserter("filter-inserter", override_stack_size="incorrect") with pytest.raises(DataFormatError): - FilterInserter( - "filter-inserter", connections="incorrect" - ) + FilterInserter("filter-inserter", connections="incorrect") with pytest.raises(DataFormatError): FilterInserter( "filter-inserter", @@ -136,30 +138,63 @@ def test_set_filter_mode(self): def test_set_item_filter(self): inserter = FilterInserter("filter-inserter") inserter.set_item_filter(0, "wooden-chest") - assert inserter.filters == Filters(root=[{"index": 1, "name": "wooden-chest"}]) + assert inserter.filters == [FilterEntry(**{"index": 1, "name": "wooden-chest"})] # Modify in place inserter.set_item_filter(0, "iron-chest") - assert inserter.filters == Filters(root=[{"index": 1, "name": "iron-chest"}]) + assert inserter.filters == [FilterEntry(**{"index": 1, "name": "iron-chest"})] inserter.filters = None assert inserter.filters == None - with pytest.raises(IndexError): + with pytest.raises(DataFormatError): inserter.set_item_filter(100, "wooden-chest") - - with pytest.raises(InvalidItemError): - inserter.set_item_filter(0, "unknown-item") - assert inserter.filters == None + with pytest.warns(UnknownItemWarning): + inserter.set_item_filter(0, "unknown-item") + assert inserter.filters == [FilterEntry(**{"index": 1, "name": "unknown-item"})] # Init if None inserter.set_item_filter(0, "wooden-chest") inserter.set_item_filter(1, "iron-chest") - assert inserter.filters == Filters(root=[{"index": 1, "name": "wooden-chest"}, {"index": 2, "name": "iron-chest"}]) + assert inserter.filters == [ + FilterEntry(**{"index": 1, "name": "wooden-chest"}), + FilterEntry(**{"index": 2, "name": "iron-chest"}), + ] # Delete if set to None inserter.set_item_filter(1, None) - assert inserter.filters == Filters(root=[{"index": 1, "name": "wooden-chest"}]) + assert inserter.filters == [FilterEntry(**{"index": 1, "name": "wooden-chest"})] + + with pytest.raises(DataFormatError): + inserter.set_item_filter("incorrect", "incorrect") + + def test_set_item_filters(self): + inserter = FilterInserter("filter-inserter") + + # Shorthand + inserter.set_item_filters("iron-plate", "copper-plate", "steel-plate") + assert inserter.filters == [ + FilterEntry(index=1, name="iron-plate"), + FilterEntry(index=2, name="copper-plate"), + FilterEntry(index=3, name="steel-plate"), + ] + + # Longhand + longhand = [ + {"index": 1, "name": "iron-plate"}, + {"index": 2, "name": "copper-plate"}, + {"index": 3, "name": "steel-plate"}, + ] + inserter.set_item_filters(*longhand) + assert inserter.filters == [ + FilterEntry(index=1, name="iron-plate"), + FilterEntry(index=2, name="copper-plate"), + FilterEntry(index=3, name="steel-plate"), + ] + + # None case + inserter.set_item_filters(None) + assert inserter.filters == None def test_mergable_with(self): inserter1 = FilterInserter("filter-inserter") @@ -199,7 +234,7 @@ def test_merge(self): assert inserter1.filter_mode == "whitelist" assert inserter1.override_stack_size == 1 - assert inserter1.filters == Filters(root=[{"name": "coal", "index": 1}]) + assert inserter1.filters == [FilterEntry(**{"name": "coal", "index": 1})] assert inserter1.tags == {"some": "stuff"} def test_eq(self): diff --git a/test/prototypes/test_furnace.py b/test/prototypes/test_furnace.py index cbbd2a8..c639491 100644 --- a/test/prototypes/test_furnace.py +++ b/test/prototypes/test_furnace.py @@ -1,14 +1,17 @@ # test_furnace.py from draftsman.entity import Furnace, furnaces, Container -from draftsman.error import InvalidItemError +from draftsman.error import DataFormatError from draftsman.warning import ( ModuleCapacityWarning, ModuleNotAllowedWarning, - ItemLimitationWarning, ItemCapacityWarning, + ItemLimitationWarning, + FuelCapacityWarning, + FuelLimitationWarning, UnknownEntityWarning, - UnknownKeywordWarning + UnknownItemWarning, + UnknownKeywordWarning, ) from collections.abc import Hashable @@ -29,8 +32,8 @@ def test_constructor_init(self): def test_set_item_request(self): furnace = Furnace("stone-furnace") - - print(furnace.allowed_modules) + assert furnace.allowed_modules == set() + assert furnace.total_module_slots == 0 # Module on stone furnace disallowed with pytest.warns(ModuleNotAllowedWarning): @@ -38,25 +41,49 @@ def test_set_item_request(self): assert furnace.items == {"speed-module": 2} # Too much fuel - with pytest.warns(ItemCapacityWarning): - furnace.set_item_request("coal", 100) - assert furnace.items == {"speed-module": 2, "coal": 100} + with pytest.warns(FuelCapacityWarning): + furnace.items = {"coal": 100} + assert furnace.items == {"coal": 100} # Fuel, but not used - with pytest.warns(ItemLimitationWarning): - furnace.set_item_request("uranium-fuel-cell", 1) - assert furnace.items == {"speed-module": 2, "coal": 100, "uranium-fuel-cell": 1} + with pytest.warns(FuelLimitationWarning): + furnace.items = {"uranium-fuel-cell": 1} + assert furnace.items == {"uranium-fuel-cell": 1} furnace = Furnace("electric-furnace") + assert furnace.allowed_modules == { + "speed-module", + "speed-module-2", + "speed-module-3", + "effectivity-module", + "effectivity-module-2", + "effectivity-module-3", + "productivity-module", + "productivity-module-2", + "productivity-module-3", + } + assert furnace.total_module_slots == 2 # Module on electric furnace furnace.set_item_request("productivity-module-3", 2) assert furnace.items == {"productivity-module-3": 2} assert furnace.module_slots_occupied == 2 + with pytest.warns(ModuleCapacityWarning): + furnace.set_item_request("speed-module", 2) + assert furnace.items == {"productivity-module-3": 2, "speed-module": 2} + assert furnace.module_slots_occupied == 4 + + furnace.items = None + # Fuel on electric furnace with pytest.warns(ItemLimitationWarning): furnace.set_item_request("coal", 100) - assert furnace.items == {"productivity-module-3": 2, "coal": 100} + assert furnace.items == {"coal": 100} + + # Too much of valid ingredient input + with pytest.warns(ItemCapacityWarning): + furnace.items = {"iron-ore": 100} # 2 stacks instead of 1 + assert furnace.items == {"iron-ore": 100} # Non smeltable item and not fuel furnace.items = {} @@ -64,23 +91,26 @@ def test_set_item_request(self): furnace.set_item_request("copper-plate", 100) assert furnace.items == {"copper-plate": 100} assert furnace.module_slots_occupied == 0 + assert furnace.fuel_slots_occupied == 0 furnace.items = {} assert furnace.items == {} assert furnace.module_slots_occupied == 0 + assert furnace.fuel_slots_occupied == 0 # Errors - with pytest.raises(TypeError): - furnace.set_item_request("incorrect", "nonsense") - # with pytest.raises(InvalidItemError): # TODO - # furnace.set_item_request("incorrect", 100) - with pytest.raises(TypeError): + with pytest.raises(DataFormatError): + furnace.set_item_request("unknown", "incorrect") + with pytest.warns(UnknownItemWarning): + furnace.set_item_request("unknown", 100) + with pytest.raises(DataFormatError): furnace.set_item_request("speed-module-2", TypeError) - # with pytest.raises(ValueError): # TODO - # furnace.set_item_request("speed-module-2", -1) + with pytest.raises(DataFormatError): + furnace.set_item_request("speed-module-2", -1) - assert furnace.items == {} + assert furnace.items == {"unknown": 100} assert furnace.module_slots_occupied == 0 + assert furnace.fuel_slots_occupied == 0 def test_mergable_with(self): furnace1 = Furnace("stone-furnace") diff --git a/test/prototypes/test_heat_interface.py b/test/prototypes/test_heat_interface.py index 65be598..af04828 100644 --- a/test/prototypes/test_heat_interface.py +++ b/test/prototypes/test_heat_interface.py @@ -1,8 +1,13 @@ # test_heat_interface.py +from draftsman.constants import ValidationMode from draftsman.entity import HeatInterface, heat_interfaces, Container -from draftsman.error import DataFormatError, InvalidModeError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning, TemperatureRangeWarning +from draftsman.error import DataFormatError +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + TemperatureRangeWarning, +) from collections.abc import Hashable import pytest @@ -21,11 +26,12 @@ def test_contstructor_init(self): # Warnings with pytest.warns(UnknownKeywordWarning): HeatInterface(unused_keyword="whatever") - with pytest.warns(TemperatureRangeWarning): - HeatInterface(temperature=100_000) with pytest.warns(UnknownEntityWarning): HeatInterface("this is not a heat interface") + with pytest.warns(TemperatureRangeWarning): + HeatInterface(temperature=100_000, validate="pedantic") + # Errors with pytest.raises(DataFormatError): HeatInterface(temperature="incorrect") @@ -38,10 +44,19 @@ def test_set_temperature(self): interface.temperature = None assert interface.temperature == None - # Warnings + # No warnings on strict + interface.temperature = -1000 + assert interface.temperature == -1000 + + # Single warning on pedantic + interface.validate_assignment = "pedantic" + assert interface.validate_assignment == ValidationMode.PEDANTIC + interface.temperature = 100 + assert interface.temperature == 100 with pytest.warns(TemperatureRangeWarning): interface.temperature = -1000 - + assert interface.temperature == -1000 + # Errors with pytest.raises(DataFormatError): interface.temperature = "incorrect" @@ -50,7 +65,7 @@ def test_set_mode(self): interface = HeatInterface() interface.mode = "exactly" assert interface.mode == "exactly" - + interface.mode = None assert interface.mode == None diff --git a/test/prototypes/test_infinity_container.py b/test/prototypes/test_infinity_container.py index 71f8c5a..c669def 100644 --- a/test/prototypes/test_infinity_container.py +++ b/test/prototypes/test_infinity_container.py @@ -1,10 +1,8 @@ # test_infinity_container.py +from draftsman.constants import ValidationMode from draftsman.entity import InfinityContainer, infinity_containers, Container from draftsman.error import ( - InvalidEntityError, - InvalidItemError, - InvalidModeError, DataFormatError, ) from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning @@ -55,19 +53,21 @@ def test_set_infinity_settings(self): {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"} ], } - assert container.infinity_settings == InfinityContainer.Format.InfinitySettings(**{ - "remove_unfiltered_items": True, - "filters": [ - {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"} - ], - }) + assert container.infinity_settings == InfinityContainer.Format.InfinitySettings( + **{ + "remove_unfiltered_items": True, + "filters": [ + {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"} + ], + } + ) container.infinity_settings = None assert container.infinity_settings == None with pytest.warns(UnknownKeywordWarning): container.infinity_settings = {"this is": ["incorrect", "for", "sure"]} - + # Errors with pytest.raises(DataFormatError): container.infinity_settings = "incorrect" @@ -77,63 +77,78 @@ def test_set_remove_unfiltered_items(self): container.remove_unfiltered_items = True assert container.remove_unfiltered_items == True - container.remove_unfiltered_items = None assert container.remove_unfiltered_items == None with pytest.raises(DataFormatError): container.remove_unfiltered_items = "incorrect" + container.validate_assignment = "none" + assert container.validate_assignment == ValidationMode.NONE + + container.remove_unfiltered_items = "incorrect" + assert container.remove_unfiltered_items == "incorrect" + def test_set_infinity_filter(self): container = InfinityContainer() settings = InfinityContainer.Format.InfinitySettings - + container.set_infinity_filter(0, "iron-ore", "at-least", 100) - assert container.infinity_settings == settings(**{ - "filters": [ - {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"} - ] - }) + assert container.infinity_settings == settings( + **{ + "filters": [ + {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"} + ] + } + ) container.set_infinity_filter(1, "copper-ore", "exactly", 200) - assert container.infinity_settings == settings(**{ - "filters": [ - {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"}, - {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"}, - ] - }) + assert container.infinity_settings == settings( + **{ + "filters": [ + {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"}, + {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"}, + ] + } + ) container.set_infinity_filter(0, "uranium-ore", "at-least", 1000) - assert container.infinity_settings == settings(**{ - "filters": [ - { - "index": 1, - "name": "uranium-ore", - "count": 1000, - "mode": "at-least", - }, - {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"}, - ] - }) + assert container.infinity_settings == settings( + **{ + "filters": [ + { + "index": 1, + "name": "uranium-ore", + "count": 1000, + "mode": "at-least", + }, + {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"}, + ] + } + ) container.set_infinity_filter(0, None) - assert container.infinity_settings == settings(**{ - "filters": [ - {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"} - ] - }) + assert container.infinity_settings == settings( + **{ + "filters": [ + {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"} + ] + } + ) # Default count container.set_infinity_filter(0, "iron-ore", "at-least") - assert container.infinity_settings == settings(**{ - "filters": [ - {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"}, - {"index": 1, "name": "iron-ore", "count": 50, "mode": "at-least"}, - ] - }) + assert container.infinity_settings == settings( + **{ + "filters": [ + {"index": 2, "name": "copper-ore", "count": 200, "mode": "exactly"}, + {"index": 1, "name": "iron-ore", "count": 50, "mode": "at-least"}, + ] + } + ) - with pytest.raises(ValueError): # TODO fix + with pytest.raises(ValueError): # TODO fix container.set_infinity_filter("incorrect", "iron-ore") with pytest.raises(DataFormatError): container.set_infinity_filter(0, TypeError) @@ -190,12 +205,22 @@ def test_merge(self): del container2 assert container1.items == {"copper-plate": 100} - assert container1.infinity_settings == InfinityContainer.Format.InfinitySettings(**{ - "remove_unfiltered_items": True, - "filters": [ - {"index": 1, "name": "iron-ore", "count": 100, "mode": "at-least"} - ], - }) + assert ( + container1.infinity_settings + == InfinityContainer.Format.InfinitySettings( + **{ + "remove_unfiltered_items": True, + "filters": [ + { + "index": 1, + "name": "iron-ore", + "count": 100, + "mode": "at-least", + } + ], + } + ) + ) def test_eq(self): container1 = InfinityContainer("infinity-chest") diff --git a/test/prototypes/test_infinity_pipe.py b/test/prototypes/test_infinity_pipe.py index b4c5f9e..fe2f180 100644 --- a/test/prototypes/test_infinity_pipe.py +++ b/test/prototypes/test_infinity_pipe.py @@ -1,8 +1,13 @@ # test_infinity_pipe.py +from draftsman.constants import ValidationMode from draftsman.entity import InfinityPipe, infinity_pipes, Container from draftsman.error import DataFormatError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + UnknownEntityWarning, + UnknownFluidWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -49,12 +54,14 @@ def test_set_infinity_settings(self): "mode": "at-least", "temperature": 500, } - assert pipe.infinity_settings == InfinityPipe.Format.InfinitySettings(**{ - "name": "steam", - "percentage": 100, - "mode": "at-least", - "temperature": 500, - }) + assert pipe.infinity_settings == InfinityPipe.Format.InfinitySettings( + **{ + "name": "steam", + "percentage": 100, + "mode": "at-least", + "temperature": 500, + } + ) pipe.infinity_settings = None assert pipe.infinity_settings == None @@ -70,12 +77,14 @@ def test_set_infinity_settings(self): def test_set_infinite_fluid(self): pipe = InfinityPipe() pipe.set_infinite_fluid("steam", 1.0, "at-least", 500) - assert pipe.infinity_settings == InfinityPipe.Format.InfinitySettings(**{ - "name": "steam", - "percentage": 1.0, - "mode": "at-least", - "temperature": 500, - }) + assert pipe.infinity_settings == InfinityPipe.Format.InfinitySettings( + **{ + "name": "steam", + "percentage": 1.0, + "mode": "at-least", + "temperature": 500, + } + ) with pytest.raises(DataFormatError): pipe.set_infinite_fluid("steam", 1, "at-least", -100) @@ -103,18 +112,28 @@ def test_set_infinite_fluid_name(self): pipe.infinite_fluid_name = None assert pipe.infinite_fluid_name == None - # TODO: warn if not a fluid name - # with pytest.warns(UnknownFluidWarning): - # pipe.infinite_fluid_name = "incorrect" + with pytest.warns(UnknownFluidWarning): + pipe.infinite_fluid_name = "incorrect" with pytest.raises(DataFormatError): pipe.infinite_fluid_name = TypeError + pipe.validate_assignment = "none" + assert pipe.validate_assignment == ValidationMode.NONE + + pipe.infinite_fluid_name = False + assert pipe.infinite_fluid_name == False + assert pipe.to_dict() == { + "name": "infinity-pipe", + "position": {"x": 0.5, "y": 0.5}, + "infinity_settings": {"name": False}, + } + def test_set_infinite_fluid_percentage(self): pipe = InfinityPipe() pipe.infinite_fluid_percentage = 0.5 assert pipe.infinite_fluid_percentage == 0.5 - + pipe.infinite_fluid_percentage = None assert pipe.infinite_fluid_percentage == None @@ -123,11 +142,22 @@ def test_set_infinite_fluid_percentage(self): with pytest.raises(DataFormatError): pipe.infinite_fluid_percentage = -1 + pipe.validate_assignment = "none" + assert pipe.validate_assignment == ValidationMode.NONE + + pipe.infinite_fluid_percentage = -1 + assert pipe.infinite_fluid_percentage == -1 + assert pipe.to_dict() == { + "name": "infinity-pipe", + "position": {"x": 0.5, "y": 0.5}, + "infinity_settings": {"percentage": -1}, + } + def test_set_infinite_fluid_mode(self): pipe = InfinityPipe() pipe.infinite_fluid_mode = "at-most" assert pipe.infinite_fluid_mode == "at-most" - + pipe.infinite_fluid_mode = None assert pipe.infinite_fluid_mode == None @@ -136,8 +166,19 @@ def test_set_infinite_fluid_mode(self): with pytest.raises(DataFormatError): pipe.infinite_fluid_mode = "incorrect" + pipe.validate_assignment = "none" + assert pipe.validate_assignment == ValidationMode.NONE + + pipe.infinite_fluid_mode = "incorrect" + assert pipe.infinite_fluid_mode == "incorrect" + assert pipe.to_dict() == { + "name": "infinity-pipe", + "position": {"x": 0.5, "y": 0.5}, + "infinity_settings": {"mode": "incorrect"}, + } + def test_set_infinite_fluid_temperature(self): - pipe = InfinityPipe() + pipe = InfinityPipe("infinity-pipe") # Cannot be set when 'name' is None with pytest.raises(DataFormatError): pipe.infinite_fluid_temperature = 200 @@ -146,13 +187,20 @@ def test_set_infinite_fluid_temperature(self): pipe.infinite_fluid_temperature = 200 assert pipe.infinite_fluid_name == "steam" assert pipe.infinite_fluid_temperature == 200 - + # Swapping to water will make the value exceed its maximum temperature with pytest.raises(DataFormatError): pipe.infinite_fluid_name = "water" assert pipe.infinite_fluid_name == "water" assert pipe.infinite_fluid_temperature == 200 + # Swapping to an unknown fluid name should issue no temperature warning, + # but a warning about the unrecognized name instead + with pytest.warns(UnknownFluidWarning): + pipe.infinite_fluid_name = "wrong" + assert pipe.infinite_fluid_name == "wrong" + assert pipe.infinite_fluid_temperature == 200 + # removing temperature should have no effect pipe.infinite_fluid_temperature = None assert pipe.infinite_fluid_temperature == None @@ -160,6 +208,19 @@ def test_set_infinite_fluid_temperature(self): with pytest.raises(DataFormatError): pipe.infinite_fluid_temperature = TypeError + pipe = InfinityPipe("infinity-pipe") + + pipe.validate_assignment = "none" + assert pipe.validate_assignment == ValidationMode.NONE + + pipe.infinite_fluid_temperature = "incorrect" + assert pipe.infinite_fluid_temperature == "incorrect" + assert pipe.to_dict() == { + "name": "infinity-pipe", + "position": {"x": 0.5, "y": 0.5}, + "infinity_settings": {"temperature": "incorrect"}, + } + def test_mergable_with(self): pipe1 = InfinityPipe("infinity-pipe") pipe2 = InfinityPipe( @@ -197,12 +258,14 @@ def test_merge(self): pipe1.merge(pipe2) del pipe2 - assert pipe1.infinity_settings == InfinityPipe.Format.InfinitySettings(**{ - "name": "steam", - "percentage": 100, - "mode": "at-least", - "temperature": 500, - }) + assert pipe1.infinity_settings == InfinityPipe.Format.InfinitySettings( + **{ + "name": "steam", + "percentage": 100, + "mode": "at-least", + "temperature": 500, + } + ) assert pipe1.tags == {"some": "stuff"} def test_eq(self): diff --git a/test/prototypes/test_inserter.py b/test/prototypes/test_inserter.py index fa2d597..eccf23a 100644 --- a/test/prototypes/test_inserter.py +++ b/test/prototypes/test_inserter.py @@ -1,9 +1,19 @@ # test_inserter.py -from draftsman.constants import Direction, ReadMode +from draftsman.constants import ( + Direction, + ReadMode, + ValidationMode, + InserterModeOfOperation, +) from draftsman.entity import Inserter, inserters, Container from draftsman.error import DataFormatError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.signatures import SignalID +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + UnknownSignalWarning, +) from collections.abc import Hashable import pytest @@ -95,9 +105,222 @@ def test_constructor_init(self): Inserter("inserter", connections="incorrect") with pytest.raises(DataFormatError): - Inserter( - "inserter", control_behavior="incorrect" - ) + Inserter("inserter", control_behavior="incorrect") + + def test_set_read_contents(self): + inserter = Inserter("inserter") + assert inserter.read_hand_contents == None + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + } + + inserter.read_hand_contents = True + assert inserter.read_hand_contents == True + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_read_hand_contents": True}, + } + + inserter.read_hand_contents = None + assert inserter.read_hand_contents == None + assert inserter.control_behavior == Inserter.Format.ControlBehavior() + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + } + + with pytest.raises(DataFormatError): + inserter.read_hand_contents = "incorrect" + assert inserter.read_hand_contents == None + + inserter.validate_assignment = "none" + assert inserter.validate_assignment == ValidationMode.NONE + + inserter.read_hand_contents = "incorrect" + assert inserter.read_hand_contents == "incorrect" + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_read_hand_contents": "incorrect"}, + } + + def test_set_read_mode(self): + inserter = Inserter("inserter") + assert inserter.read_mode == None + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + } + + inserter.read_mode = ReadMode.HOLD + assert inserter.read_mode == ReadMode.HOLD + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_hand_read_mode": ReadMode.HOLD}, + } + + inserter.read_mode = None + assert inserter.read_mode == None + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + } + + with pytest.raises(DataFormatError): + inserter.read_mode = "incorrect" + assert inserter.read_mode == None + + inserter.validate_assignment = "none" + assert inserter.validate_assignment == ValidationMode.NONE + + inserter.read_mode = "incorrect" + assert inserter.read_mode == "incorrect" + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_hand_read_mode": "incorrect"}, + } + + def test_mode_of_operation(self): + inserter = Inserter("inserter") + assert inserter.mode_of_operation == None + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + } + + # Set int + inserter.mode_of_operation = 0 + assert inserter.mode_of_operation == InserterModeOfOperation.ENABLE_DISABLE + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_mode_of_operation": 0}, + } + + # Set enum + inserter.mode_of_operation = InserterModeOfOperation.READ_HAND_CONTENTS + assert inserter.mode_of_operation == InserterModeOfOperation.READ_HAND_CONTENTS + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_mode_of_operation": 2}, + } + + # Set int out of enum range + with pytest.raises(DataFormatError): + inserter.mode_of_operation = 5 + + # Turn off validation + inserter.validate_assignment = "none" + assert inserter.validate_assignment == ValidationMode.NONE + inserter.mode_of_operation = 5 + assert inserter.mode_of_operation == 5 + assert inserter.to_dict() == { + "name": "inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_mode_of_operation": 5}, + } + + def test_set_circuit_stack_size_enabled(self): + inserter = Inserter("stack-inserter") + assert inserter.circuit_stack_size_enabled == None + + inserter.circuit_stack_size_enabled = True + assert inserter.circuit_stack_size_enabled == True + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_set_stack_size": True}, + } + + inserter.circuit_stack_size_enabled = False + assert inserter.circuit_stack_size_enabled == False + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_set_stack_size": False}, + } + + with pytest.raises(DataFormatError): + inserter.circuit_stack_size_enabled = "incorrect" + + inserter.validate_assignment = "none" + assert inserter.validate_assignment == ValidationMode.NONE + inserter.circuit_stack_size_enabled = "incorrect" + assert inserter.circuit_stack_size_enabled == "incorrect" + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_set_stack_size": "incorrect"}, + } + + def test_set_stack_size_control_signal(self): + inserter = Inserter("stack-inserter") + assert inserter.stack_size_control_signal == None + + # Shorthand + inserter.stack_size_control_signal = "signal-S" + assert inserter.stack_size_control_signal == SignalID( + name="signal-S", type="virtual" + ) + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "stack_control_input_signal": {"name": "signal-S", "type": "virtual"} + }, + } + + # Longhand + inserter.stack_size_control_signal = {"name": "signal-S", "type": "virtual"} + assert inserter.stack_size_control_signal == SignalID( + name="signal-S", type="virtual" + ) + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "stack_control_input_signal": {"name": "signal-S", "type": "virtual"} + }, + } + + # Unrecognized shorthand + with pytest.raises(DataFormatError): + inserter.stack_size_control_signal = "unknown" + assert inserter.stack_size_control_signal == SignalID( + name="signal-S", type="virtual" + ) + + # Unrecognized longhand + with pytest.warns(UnknownSignalWarning): + inserter.stack_size_control_signal = {"name": "unknown", "type": "item"} + assert inserter.stack_size_control_signal == SignalID( + name="unknown", type="item" + ) + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "stack_control_input_signal": {"name": "unknown", "type": "item"} + }, + } + + with pytest.raises(DataFormatError): + inserter.stack_size_control_signal = ["very", "wrong"] + + inserter.validate_assignment = "none" + assert inserter.validate_assignment == ValidationMode.NONE + inserter.stack_size_control_signal = ["very", "wrong"] + assert inserter.stack_size_control_signal == ["very", "wrong"] + assert inserter.to_dict() == { + "name": "stack-inserter", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"stack_control_input_signal": ["very", "wrong"]}, + } def test_power_and_circuit_flags(self): for name in inserters: diff --git a/test/prototypes/test_lab.py b/test/prototypes/test_lab.py index 3ed2a70..20bc8be 100644 --- a/test/prototypes/test_lab.py +++ b/test/prototypes/test_lab.py @@ -1,12 +1,13 @@ # test_lab.py from draftsman.entity import Lab, labs, Container -from draftsman.error import InvalidEntityError, InvalidItemError +from draftsman.error import DataFormatError from draftsman.warning import ( ModuleCapacityWarning, ItemLimitationWarning, UnknownEntityWarning, - UnknownKeywordWarning + UnknownItemWarning, + UnknownKeywordWarning, ) from collections.abc import Hashable @@ -69,16 +70,16 @@ def test_set_item_request(self): lab.items = {} assert lab.module_slots_occupied == 0 - with pytest.raises(TypeError): + with pytest.raises(DataFormatError): lab.set_item_request(TypeError, 100) - # with pytest.raises(InvalidItemError): # TODO - # lab.set_item_request("incorrect", 100) - with pytest.raises(TypeError): + with pytest.warns(UnknownItemWarning): + lab.set_item_request("unknown", 100) + with pytest.raises(DataFormatError): lab.set_item_request("logistic-science-pack", TypeError) - # with pytest.raises(ValueError): # TODO - # lab.set_item_request("logistic-science-pack", -1) + with pytest.raises(DataFormatError): + lab.set_item_request("logistic-science-pack", -1) - assert lab.items == {} + assert lab.items == {"unknown": 100} assert lab.module_slots_occupied == 0 def test_mergable_with(self): diff --git a/test/prototypes/test_lamp.py b/test/prototypes/test_lamp.py index 9030be5..1237a57 100644 --- a/test/prototypes/test_lamp.py +++ b/test/prototypes/test_lamp.py @@ -24,7 +24,7 @@ def test_constructor_init(self): Lamp(control_behavior={"unused_key": "something"}) with pytest.warns(UnknownEntityWarning): Lamp("this is not a lamp") - + # Errors with pytest.raises(DataFormatError): Lamp(control_behavior="incorrect") @@ -33,14 +33,26 @@ def test_set_use_colors(self): lamp = Lamp("small-lamp") lamp.use_colors = True assert lamp.use_colors == True - assert lamp.control_behavior == Lamp.Format.ControlBehavior(**{"use_colors": True}) - + assert lamp.control_behavior == Lamp.Format.ControlBehavior( + **{"use_colors": True} + ) + lamp.use_colors = None assert lamp.use_colors == None - + with pytest.raises(DataFormatError): lamp.use_colors = "incorrect" + lamp.validate_assignment = "none" + + lamp.use_colors = "incorrect" + assert lamp.use_colors == "incorrect" + assert lamp.to_dict() == { + "name": "small-lamp", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"use_colors": "incorrect"}, + } + def test_mergable_with(self): lamp1 = Lamp("small-lamp") lamp2 = Lamp( diff --git a/test/prototypes/test_linked_belt.py b/test/prototypes/test_linked_belt.py index 8c30b96..7854895 100644 --- a/test/prototypes/test_linked_belt.py +++ b/test/prototypes/test_linked_belt.py @@ -9,6 +9,8 @@ # For compatibility with versions of Factorio prior to 1.1.6 pytest.mark.skipif(len(linked_belts) == 0, "No linked belts to test") + + class TestLinkedBelt: def test_constructor_init(self): linked_belt = LinkedBelt("linked-belt") diff --git a/test/prototypes/test_linked_container.py b/test/prototypes/test_linked_container.py index fc774ae..d90d34d 100644 --- a/test/prototypes/test_linked_container.py +++ b/test/prototypes/test_linked_container.py @@ -47,7 +47,7 @@ def test_set_links(self): container.link_id = None assert container.link_id == 0 - + with pytest.raises(DataFormatError): container.link_id = "incorrect" diff --git a/test/prototypes/test_loader.py b/test/prototypes/test_loader.py index a40e1a0..8c49098 100644 --- a/test/prototypes/test_loader.py +++ b/test/prototypes/test_loader.py @@ -1,7 +1,7 @@ # test_loader.py from draftsman.entity import Loader, loaders, Container -from draftsman.signatures import Filters +from draftsman.signatures import FilterEntry from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning from collections.abc import Hashable @@ -47,7 +47,7 @@ def test_merge(self): loader1.merge(loader2) del loader2 - assert loader1.filters == Filters([{"name": "coal", "index": 1}]) + assert loader1.filters == [FilterEntry(**{"name": "coal", "index": 1})] assert loader1.io_type == "input" assert loader1.tags == {"some": "stuff"} diff --git a/test/prototypes/test_locomotive.py b/test/prototypes/test_locomotive.py index 30c7bb0..169c36e 100644 --- a/test/prototypes/test_locomotive.py +++ b/test/prototypes/test_locomotive.py @@ -64,7 +64,7 @@ def test_merge(self): "name": "locomotive", "position": {"x": 1.0, "y": 3.0}, "color": {"r": 100, "g": 100, "b": 100}, - "tags": {"some": "stuff"} + "tags": {"some": "stuff"}, } def test_eq(self): diff --git a/test/prototypes/test_logistic_buffer_container.py b/test/prototypes/test_logistic_buffer_container.py index 46acd5d..5f5e21e 100644 --- a/test/prototypes/test_logistic_buffer_container.py +++ b/test/prototypes/test_logistic_buffer_container.py @@ -6,7 +6,7 @@ Container, ) from draftsman.error import DataFormatError -from draftsman.signatures import RequestFilters +from draftsman.signatures import RequestFilter from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning from collections.abc import Hashable @@ -89,9 +89,7 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): LogisticBufferContainer("logistic-chest-buffer", bar="not even trying") with pytest.raises(DataFormatError): - LogisticBufferContainer( - "logistic-chest-buffer", connections="incorrect" - ) + LogisticBufferContainer("logistic-chest-buffer", connections="incorrect") with pytest.raises(DataFormatError): LogisticBufferContainer( "logistic-chest-buffer", request_filters={"this is": ["very", "wrong"]} @@ -137,9 +135,9 @@ def test_merge(self): del container2 assert container1.bar == 10 - assert container1.request_filters == RequestFilters(root=[ - {"name": "utility-science-pack", "index": 1, "count": 10} - ]) + assert container1.request_filters == [ + RequestFilter(**{"name": "utility-science-pack", "index": 1, "count": 10}) + ] assert container1.tags == {"some": "stuff"} def test_eq(self): diff --git a/test/prototypes/test_logistic_request_container.py b/test/prototypes/test_logistic_request_container.py index 7391e59..7bf40c9 100644 --- a/test/prototypes/test_logistic_request_container.py +++ b/test/prototypes/test_logistic_request_container.py @@ -1,13 +1,18 @@ # test_logistic_request_container.py +from draftsman.constants import LogisticModeOfOperation, ValidationMode from draftsman.entity import ( LogisticRequestContainer, logistic_request_containers, Container, ) from draftsman.error import DataFormatError -from draftsman.signatures import RequestFilters -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.signatures import RequestFilter +from draftsman.warning import ( + UnknownEntityWarning, + UnknownItemWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -115,6 +120,193 @@ def test_power_and_circuit_flags(self): assert container.circuit_connectable == True assert container.dual_circuit_connectable == False + def test_logistics_mode(self): + container = LogisticRequestContainer("logistic-chest-requester") + assert container.mode_of_operation == None + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + } + + # Set int + container.mode_of_operation = 0 + assert container.mode_of_operation == LogisticModeOfOperation.SEND_CONTENTS + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_mode_of_operation": 0}, + } + + # Set Enum + container.mode_of_operation = LogisticModeOfOperation.SET_REQUESTS + assert container.mode_of_operation == LogisticModeOfOperation.SET_REQUESTS + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_mode_of_operation": 1}, + } + + # Set int outside range of enum + with pytest.raises(DataFormatError): + container.mode_of_operation = -1 + + # Turn of validation + container.validate_assignment = "none" + assert container.validate_assignment == ValidationMode.NONE + container.mode_of_operation = -1 + assert container.mode_of_operation == -1 + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_mode_of_operation": -1}, + } + + def test_set_requests(self): + container = LogisticRequestContainer("logistic-chest-requester") + + # Shorthand + container.request_filters = [ + ("iron-ore", 100), + ("copper-ore", 200), + ("coal", 300), + ] + assert container.request_filters == [ + RequestFilter(index=1, name="iron-ore", count=100), + RequestFilter(index=2, name="copper-ore", count=200), + RequestFilter(index=3, name="coal", count=300), + ] + + # Longhand + container.request_filters = [ + {"index": 1, "name": "iron-ore", "count": 100}, + {"index": 2, "name": "copper-ore", "count": 200}, + {"index": 3, "name": "coal", "count": 300}, + ] + assert container.request_filters == [ + RequestFilter(index=1, name="iron-ore", count=100), + RequestFilter(index=2, name="copper-ore", count=200), + RequestFilter(index=3, name="coal", count=300), + ] + + def test_set_request_filter(self): + container = LogisticRequestContainer("logistic-chest-requester") + + container.set_request_filter(0, "iron-ore", 100) + container.set_request_filter(1, "copper-ore", 200) + assert container.request_filters == [ + RequestFilter(index=1, name="iron-ore", count=100), + RequestFilter(index=2, name="copper-ore", count=200), + ] + + # Replace existing + container.set_request_filter(1, "wooden-chest", 123) + assert container.request_filters == [ + RequestFilter(index=1, name="iron-ore", count=100), + RequestFilter(index=2, name="wooden-chest", count=123), + ] + + # Omitted count + container.set_request_filter(0, "steel-chest") + assert container.request_filters == [ + RequestFilter(index=1, name="steel-chest", count=None), + RequestFilter(index=2, name="wooden-chest", count=123), + ] + + # Delete existing + container.set_request_filter(1, None) + assert container.request_filters == [ + RequestFilter(index=1, name="steel-chest", count=None), + ] + + # None case + container.request_filters = None + assert container.request_filters == None + + # Create from None + container.set_request_filter(0, "copper-ore", 200) + assert container.request_filters == [ + RequestFilter(index=1, name="copper-ore", count=200), + ] + + with pytest.raises(DataFormatError): + container.set_request_filter("incorrect", "incorrect") + + # Unknown item + # No warning with minimum + container.validate_assignment = "minimum" + assert container.validate_assignment == ValidationMode.MINIMUM + container.set_request_filter(0, "who-knows?", 100) + assert container.request_filters == [ + RequestFilter(index=1, name="who-knows?", count=100), + ] + + container.validate_assignment = ValidationMode.STRICT + with pytest.warns(UnknownItemWarning): + container.set_request_filter(0, "who-knows?", 100) + + def test_set_request_filters(self): + container = LogisticRequestContainer("logistic-chest-requester") + + # Shorthand + container.set_request_filters( + [("iron-ore", 100), ("copper-ore", 200), ("coal", 300)] + ) + assert container.request_filters == [ + RequestFilter(index=1, name="iron-ore", count=100), + RequestFilter(index=2, name="copper-ore", count=200), + RequestFilter(index=3, name="coal", count=300), + ] + + # Longhand + container.set_request_filters( + [ + {"index": 1, "name": "iron-ore", "count": 100}, + {"index": 2, "name": "copper-ore", "count": 200}, + {"index": 3, "name": "coal", "count": 300}, + ] + ) + assert container.request_filters == [ + RequestFilter(index=1, name="iron-ore", count=100), + RequestFilter(index=2, name="copper-ore", count=200), + RequestFilter(index=3, name="coal", count=300), + ] + + # Ensure error in all circumstances + container.validate_assignment = "none" + assert container.validate_assignment == ValidationMode.NONE + with pytest.raises(DataFormatError): + container.set_request_filters("incorrect") + + def test_request_from_buffers(self): + container = LogisticRequestContainer("logistic-chest-requester") + assert container.request_from_buffers == False + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + } + + container.request_from_buffers = True + assert container.request_from_buffers == True + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + "request_from_buffers": True, + } + + with pytest.raises(DataFormatError): + container.request_from_buffers = "incorrect" + + container.validate_assignment = "none" + assert container.validate_assignment == ValidationMode.NONE + + container.request_from_buffers = "incorrect" + assert container.request_from_buffers == "incorrect" + assert container.to_dict() == { + "name": "logistic-chest-requester", + "position": {"x": 0.5, "y": 0.5}, + "request_from_buffers": "incorrect", + } + def test_mergable_with(self): container1 = LogisticRequestContainer("logistic-chest-requester") container2 = LogisticRequestContainer( @@ -145,9 +337,9 @@ def test_merge(self): del container2 assert container1.bar == 10 - assert container1.request_filters == RequestFilters(root=[ - {"name": "utility-science-pack", "index": 1, "count": 10} - ]) + assert container1.request_filters == [ + RequestFilter(**{"name": "utility-science-pack", "index": 1, "count": 10}) + ] assert container1.tags == {"some": "stuff"} def test_eq(self): diff --git a/test/prototypes/test_logistic_storage_container.py b/test/prototypes/test_logistic_storage_container.py index 7c2fffc..d0d0fae 100644 --- a/test/prototypes/test_logistic_storage_container.py +++ b/test/prototypes/test_logistic_storage_container.py @@ -6,7 +6,7 @@ Container, ) from draftsman.error import DataFormatError -from draftsman.signatures import RequestFilters +from draftsman.signatures import RequestFilter from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning from collections.abc import Hashable @@ -91,9 +91,7 @@ def test_constructor_init(self): LogisticStorageContainer("logistic-chest-storage", bar="not even trying") with pytest.raises(DataFormatError): - LogisticStorageContainer( - "logistic-chest-storage", connections="incorrect" - ) + LogisticStorageContainer("logistic-chest-storage", connections="incorrect") with pytest.raises(DataFormatError): LogisticStorageContainer( @@ -138,9 +136,9 @@ def test_merge(self): del container2 assert container1.bar == 10 - assert container1.request_filters == RequestFilters(root=[ - {"name": "utility-science-pack", "index": 1, "count": 0} - ]) + assert container1.request_filters == [ + RequestFilter(**{"name": "utility-science-pack", "index": 1, "count": 0}) + ] assert container1.tags == {"some": "stuff"} def test_eq(self): diff --git a/test/prototypes/test_mining_drill.py b/test/prototypes/test_mining_drill.py index 6b52eb5..3bc2083 100644 --- a/test/prototypes/test_mining_drill.py +++ b/test/prototypes/test_mining_drill.py @@ -1,6 +1,6 @@ # test_mining_drill.py -from draftsman.constants import MiningDrillReadMode +from draftsman.constants import MiningDrillReadMode, ValidationMode from draftsman.entity import MiningDrill, mining_drills, Container from draftsman.error import InvalidEntityError, InvalidItemError, DataFormatError from draftsman.warning import ( @@ -8,7 +8,7 @@ ItemLimitationWarning, UnknownEntityWarning, UnknownItemWarning, - UnknownKeywordWarning + UnknownKeywordWarning, ) from collections.abc import Hashable @@ -24,7 +24,7 @@ def test_constructor_init(self): assert drill.to_dict() == { "name": "electric-mining-drill", "position": {"x": 1.5, "y": 1.5}, - "items": {"productivity-module": 1, "productivity-module-2": 1} + "items": {"productivity-module": 1, "productivity-module-2": 1}, } # Warnings @@ -67,7 +67,7 @@ def test_set_item_request(self): mining_drill.set_item_request("speed-module-3", None) assert mining_drill.items == {"productivity-module-3": 3} - + with pytest.warns(ItemLimitationWarning): mining_drill.set_item_request("iron-ore", 2) @@ -78,24 +78,46 @@ def test_set_read_resources(self): mining_drill = MiningDrill() mining_drill.read_resources = True assert mining_drill.read_resources == True - + mining_drill.read_resources = None assert mining_drill.read_resources == None with pytest.raises(DataFormatError): mining_drill.read_resources = "incorrect" + mining_drill.validate_assignment = "none" + assert mining_drill.validate_assignment == ValidationMode.NONE + + mining_drill.read_resources = "incorrect" + assert mining_drill.read_resources == "incorrect" + assert mining_drill.to_dict() == { + "name": "burner-mining-drill", + "position": {"x": 1, "y": 1}, + "control_behavior": {"circuit_read_resources": "incorrect"}, + } + def test_set_read_mode(self): mining_drill = MiningDrill() mining_drill.read_mode = MiningDrillReadMode.UNDER_DRILL assert mining_drill.read_mode == MiningDrillReadMode.UNDER_DRILL - + mining_drill.read_mode = None assert mining_drill.read_mode == None with pytest.raises(DataFormatError): mining_drill.read_mode = "incorrect" + mining_drill.validate_assignment = "none" + assert mining_drill.validate_assignment == ValidationMode.NONE + + mining_drill.read_mode = "incorrect" + assert mining_drill.read_mode == "incorrect" + assert mining_drill.to_dict() == { + "name": "burner-mining-drill", + "position": {"x": 1, "y": 1}, + "control_behavior": {"circuit_resource_read_mode": "incorrect"}, + } + def test_mergable_with(self): drill1 = MiningDrill("electric-mining-drill") drill2 = MiningDrill( diff --git a/test/prototypes/test_offshore_pump.py b/test/prototypes/test_offshore_pump.py index 4fc1d19..f14d779 100644 --- a/test/prototypes/test_offshore_pump.py +++ b/test/prototypes/test_offshore_pump.py @@ -1,7 +1,9 @@ # test_offshore_pump.py +from draftsman.constants import ValidationMode from draftsman.entity import OffshorePump, offshore_pumps, Container from draftsman.error import DataFormatError +from draftsman.signatures import Condition from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning from collections.abc import Hashable @@ -24,6 +26,114 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): OffshorePump(control_behavior="incorrect") + def test_control_behavior(self): + pump = OffshorePump("offshore-pump") + + with pytest.raises(DataFormatError): + pump.control_behavior = "incorrect" + + pump.validate_assignment = "none" + assert pump.validate_assignment == ValidationMode.NONE + + pump.control_behavior = "incorrect" + assert pump.control_behavior == "incorrect" + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": "incorrect", + } + + def test_set_circuit_condition(self): + pump = OffshorePump("offshore-pump") + + pump.set_circuit_condition("iron-ore", ">", 1000) + assert pump.control_behavior.circuit_condition == Condition( + **{"first_signal": "iron-ore", "comparator": ">", "constant": 1000} + ) + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "circuit_condition": { + "first_signal": {"name": "iron-ore", "type": "item"}, + "comparator": ">", + "constant": 1000, + } + }, + } + + pump.set_circuit_condition("iron-ore", ">=", "copper-ore") + assert pump.control_behavior.circuit_condition == Condition( + **{ + "first_signal": "iron-ore", + "comparator": ">=", + "second_signal": "copper-ore", + } + ) + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "circuit_condition": { + "first_signal": {"name": "iron-ore", "type": "item"}, + "comparator": "≥", + "second_signal": {"name": "copper-ore", "type": "item"}, + } + }, + } + + pump.remove_circuit_condition() + assert pump.control_behavior.circuit_condition == None + + def test_connect_to_logistic_network(self): + pump = OffshorePump("offshore-pump") + assert pump.connect_to_logistic_network == False + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + } + + pump.connect_to_logistic_network = True + assert pump.connect_to_logistic_network == True + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"connect_to_logistic_network": True}, + } + + pump.connect_to_logistic_network = None + assert pump.connect_to_logistic_network == None + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + } + + with pytest.raises(DataFormatError): + pump.connect_to_logistic_network = "incorrect" + assert pump.connect_to_logistic_network == None + + pump.validate_assignment = "none" + assert pump.validate_assignment == ValidationMode.NONE + + pump.connect_to_logistic_network = "incorrect" + assert pump.connect_to_logistic_network == "incorrect" + assert pump.to_dict() == { + "name": "offshore-pump", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"connect_to_logistic_network": "incorrect"}, + } + + def test_set_logistics_condition(self): + pump = OffshorePump("offshore-pump") + + pump.set_logistic_condition("iron-ore", ">", 1000) + assert pump.control_behavior.logistic_condition == Condition( + **{"first_signal": "iron-ore", "comparator": ">", "constant": 1000} + ) + + pump.remove_logistic_condition() + assert pump.control_behavior.logistic_condition == None + def test_mergable_with(self): pump1 = OffshorePump("offshore-pump") pump2 = OffshorePump("offshore-pump", tags={"some": "stuff"}) diff --git a/test/prototypes/test_power_switch.py b/test/prototypes/test_power_switch.py index 3c609ec..cb833bf 100644 --- a/test/prototypes/test_power_switch.py +++ b/test/prototypes/test_power_switch.py @@ -31,7 +31,7 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): PowerSwitch(control_behavior="incorrect") - def test_flags(self): + def test_power_and_circuit_flags(self): for name in power_switches: power_switch = PowerSwitch(name) assert power_switch.power_connectable == True @@ -39,6 +39,9 @@ def test_flags(self): assert power_switch.circuit_connectable == True assert power_switch.dual_circuit_connectable == False + def test_circuit_wire_max_distance(self): + assert PowerSwitch("power-switch").circuit_wire_max_distance == 10.0 + def test_switch_state(self): power_switch = PowerSwitch() power_switch.switch_state = False diff --git a/test/prototypes/test_programmable_speaker.py b/test/prototypes/test_programmable_speaker.py index bc82b32..40a0016 100644 --- a/test/prototypes/test_programmable_speaker.py +++ b/test/prototypes/test_programmable_speaker.py @@ -1,5 +1,6 @@ # test_programmable_speaker.py +from draftsman.constants import ValidationMode from draftsman.entity import ProgrammableSpeaker, programmable_speakers, Container from draftsman.error import ( DraftsmanError, @@ -10,7 +11,14 @@ DataFormatError, ) from draftsman.signatures import SignalID -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning, UnknownInstrumentWarning, UnknownNoteWarning, UnknownSignalWarning, VolumeRangeWarning +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + UnknownInstrumentWarning, + UnknownNoteWarning, + UnknownSignalWarning, + VolumeRangeWarning, +) from collections.abc import Hashable import pytest @@ -109,12 +117,12 @@ def test_constructor_init(self): } # TODO: ensure this - # speaker = ProgrammableSpeaker(control_behavior={"circuit_enable_disable": True}) - # assert speaker.to_dict() == { - # "name": "programmable-speaker", - # "position": {"x": 0.5, "y": 0.5}, - # "control_behavior": {"circuit_enable_disable": True}, - # } + speaker = ProgrammableSpeaker(control_behavior={"circuit_enable_disable": True}) + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_enable_disable": True}, + } speaker = ProgrammableSpeaker( control_behavior={"circuit_parameters": {"signal_value_is_pitch": True}} ) @@ -136,7 +144,10 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): ProgrammableSpeaker(control_behavior="incorrect") - def test_flags(self): + # Test no errors when validating without context + ProgrammableSpeaker.Format.model_validate(speaker._root) + + def test_power_and_circuit_flags(self): for name in programmable_speakers: speaker = ProgrammableSpeaker(name) assert speaker.power_connectable == False @@ -163,7 +174,9 @@ def test_set_parameters(self): with pytest.warns(UnknownKeywordWarning): speaker.parameters = {"something": "unknown"} - assert speaker.parameters == ProgrammableSpeaker.Format.Parameters(something="unknown") + assert speaker.parameters == ProgrammableSpeaker.Format.Parameters( + something="unknown" + ) with pytest.raises(DataFormatError): speaker.parameters = "false" @@ -175,7 +188,9 @@ def test_set_alert_parameters(self): with pytest.warns(UnknownKeywordWarning): speaker.alert_parameters = {"something": "unknown"} - assert speaker.alert_parameters == ProgrammableSpeaker.Format.AlertParameters(something="unknown") + assert speaker.alert_parameters == ProgrammableSpeaker.Format.AlertParameters( + something="unknown" + ) with pytest.raises(DataFormatError): speaker.alert_parameters = "false" @@ -184,17 +199,31 @@ def test_set_volume(self): speaker = ProgrammableSpeaker() speaker.volume = 0.5 assert speaker.volume == 0.5 - assert speaker.parameters == ProgrammableSpeaker.Format.Parameters(**{"playback_volume": 0.5}) - + assert speaker.parameters == ProgrammableSpeaker.Format.Parameters( + **{"playback_volume": 0.5} + ) + speaker.volume = None assert speaker.volume == None - assert speaker.parameters == ProgrammableSpeaker.Format.Parameters(playback_volume=None) + assert speaker.parameters == ProgrammableSpeaker.Format.Parameters( + playback_volume=None + ) # Warnings with pytest.warns(VolumeRangeWarning): speaker.volume = 10.0 assert speaker.volume == 10.0 + # No warning + speaker.validate_assignment = ValidationMode.MINIMUM + speaker.volume = 10.0 + assert speaker.volume == 10.0 + + # Promote warnings to errors + speaker.validate_assignment = ValidationMode.PEDANTIC + with pytest.raises(DataFormatError): + speaker.volume = 10.0 + # No Error speaker.validate_assignment = "none" speaker.volume = "incorrect" @@ -211,51 +240,103 @@ def test_set_global_playback(self): speaker.global_playback = True assert speaker.global_playback == True - + speaker.global_playback = None assert speaker.global_playback == None + # Error with pytest.raises(DataFormatError): speaker.global_playback = "incorrect" + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.global_playback = "incorrect" + assert speaker.global_playback == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "parameters": {"playback_globally": "incorrect"}, + } + def test_set_show_alert(self): speaker = ProgrammableSpeaker() speaker.show_alert = True assert speaker.show_alert == True - + speaker.show_alert = None assert speaker.show_alert == None + # Error with pytest.raises(DataFormatError): speaker.show_alert = "incorrect" + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.show_alert = "incorrect" + assert speaker.show_alert == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "alert_parameters": {"show_alert": "incorrect"}, + } + def test_set_polyphony(self): speaker = ProgrammableSpeaker() speaker.allow_polyphony = True assert speaker.allow_polyphony == True - + speaker.allow_polyphony = None assert speaker.allow_polyphony == None + # Error with pytest.raises(DataFormatError): speaker.allow_polyphony = "incorrect" + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.allow_polyphony = "incorrect" + assert speaker.allow_polyphony == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "parameters": {"allow_polyphony": "incorrect"}, + } + def test_set_show_alert_on_map(self): speaker = ProgrammableSpeaker() speaker.show_alert_on_map = True assert speaker.show_alert_on_map == True - + speaker.show_alert_on_map = None assert speaker.show_alert_on_map == None + # Error with pytest.raises(DataFormatError): speaker.show_alert_on_map = "incorrect" + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.show_alert_on_map = "incorrect" + assert speaker.show_alert_on_map == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "alert_parameters": {"show_on_map": "incorrect"}, + } + def test_set_alert_icon(self): speaker = ProgrammableSpeaker() speaker.alert_icon = "signal-check" assert speaker.alert_icon == SignalID(name="signal-check", type="virtual") - + speaker.alert_icon = {"name": "signal-check", "type": "virtual"} assert speaker.alert_icon == SignalID(name="signal-check", type="virtual") @@ -271,30 +352,68 @@ def test_set_alert_icon(self): with pytest.raises(DataFormatError): speaker.alert_icon = "incorrect" + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.alert_icon = "incorrect" + assert speaker.alert_icon == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "alert_parameters": {"icon_signal_id": "incorrect"}, + } + def test_set_alert_message(self): speaker = ProgrammableSpeaker() speaker.alert_message = "some string" assert speaker.alert_message == "some string" - + speaker.alert_message = None assert speaker.alert_message == None with pytest.raises(DataFormatError): speaker.alert_message = False + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.alert_message = False + assert speaker.alert_message == False + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "alert_parameters": {"alert_message": False}, + } + def test_set_signal_value_is_pitch(self): speaker = ProgrammableSpeaker() assert speaker.signal_value_is_pitch == False speaker.signal_value_is_pitch = True assert speaker.signal_value_is_pitch == True - + speaker.signal_value_is_pitch = None assert speaker.signal_value_is_pitch == None with pytest.raises(DataFormatError): speaker.signal_value_is_pitch = "incorrect" + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.signal_value_is_pitch = "incorrect" + assert speaker.signal_value_is_pitch == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "circuit_parameters": {"signal_value_is_pitch": "incorrect"} + }, + } + def test_set_instrument_id(self): speaker = ProgrammableSpeaker() assert speaker.instrument_id == 0 @@ -313,10 +432,33 @@ def test_set_instrument_id(self): speaker.instrument_id = 100 assert speaker.instrument_id == 100 + # No warnings + speaker.validate_assignment = ValidationMode.MINIMUM + speaker.instrument_id = 100 + assert speaker.instrument_id == 100 + # Errors with pytest.raises(DataFormatError): speaker.instrument_id = TypeError + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.instrument_id = "incorrect" + assert speaker.instrument_id == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_parameters": {"instrument_id": "incorrect"}}, + } + + # Test validation with unknown programmable speaker + unknown = ProgrammableSpeaker("programmable-speaker-2", validate="none") + print(unknown._root) + unknown.instrument_id = 10 + assert unknown.instrument_id == 10 + def test_set_instrument_name(self): speaker = ProgrammableSpeaker() assert speaker.instrument_name == "alarms" @@ -329,26 +471,46 @@ def test_set_instrument_name(self): speaker.instrument_name = None assert speaker.instrument_name == None assert speaker.instrument_id == None - + # Warnings with pytest.warns(UnknownInstrumentWarning): speaker.instrument_name = "incorrect" assert speaker.instrument_id == None # Here the instrument name is not even set, which is because we have no - # way of knowing the translation of this string to an integer index + # way of knowing the translation of this string to an integer index # since it was unrecognized # If you want to use unknown instruments, use `instrument_id` instead # and translate yourself assert speaker.instrument_name == None + speaker.validate_assignment = "minimum" + assert speaker.validate_assignment == ValidationMode.MINIMUM + + # No warning because minimum + speaker.instrument_name = "unknown" + # Errors with pytest.raises(DataFormatError): speaker.instrument_name = TypeError + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.instrument_name = "incorrect" + assert speaker.instrument_name == None + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "circuit_parameters": {} # Should be omitted, but pydantic is stupid + }, + } + def test_set_note_id(self): speaker = ProgrammableSpeaker() assert speaker.note_id == 0 - + speaker.instrument_name = "alarms" speaker.note_id = 0 assert speaker.note_id == 0 @@ -370,10 +532,32 @@ def test_set_note_id(self): assert speaker.note_id == 100 assert speaker.note_name == None + # Handle the case where the instrument is unknown + with pytest.warns(UnknownInstrumentWarning): + speaker.instrument_id = 100 + + speaker.note_id = 100 + assert speaker.note_id == 100 + + # Reset + speaker.instrument_id = None + # Errors with pytest.raises(DataFormatError): speaker.note_id = TypeError + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.note_id = "incorrect" + assert speaker.note_id == "incorrect" + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_parameters": {"note_id": "incorrect"}}, + } + def test_set_note_name(self): speaker = ProgrammableSpeaker() assert speaker.instrument_id == 0 @@ -400,16 +584,46 @@ def test_set_note_name(self): speaker.note_name = "incorrect" speaker.note_id == None # Here the note name is not even set, which is because we have no - # way of knowing the translation of this string to an integer index + # way of knowing the translation of this string to an integer index # since it was unrecognized # If you want to use unknown notes, use `note_id` instead and translate # yourself speaker.note_name == None + # Handle the case where the instrument is unknown + with pytest.warns(UnknownInstrumentWarning): + speaker.instrument_id = 100 + + speaker.note_name = "unknown" + assert speaker.note_name == None + + speaker.validate_assignment = "minimum" + assert speaker.validate_assignment == ValidationMode.MINIMUM + + # No warning because minimum + speaker.note_name = "unknown" + + # Reset + speaker.instrument_id = None + # Errors with pytest.raises(DataFormatError): speaker.note_name = TypeError + # No error + speaker.validate_assignment = "none" + assert speaker.validate_assignment == ValidationMode.NONE + + speaker.note_name = "incorrect" + assert speaker.note_name == None + assert speaker.to_dict() == { + "name": "programmable-speaker", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "circuit_parameters": {} # Should be omitted, but pydantic is stupid + }, + } + def test_mergable_with(self): speaker1 = ProgrammableSpeaker("programmable-speaker") speaker2 = ProgrammableSpeaker( diff --git a/test/prototypes/test_rail_chain_signal.py b/test/prototypes/test_rail_chain_signal.py index 62358a0..b012d29 100644 --- a/test/prototypes/test_rail_chain_signal.py +++ b/test/prototypes/test_rail_chain_signal.py @@ -70,10 +70,14 @@ def test_constructor_init(self): def test_set_blue_output_signal(self): rail_signal = RailChainSignal() rail_signal.blue_output_signal = "signal-A" - assert rail_signal.blue_output_signal == SignalID(name="signal-A", type="virtual") - + assert rail_signal.blue_output_signal == SignalID( + name="signal-A", type="virtual" + ) + rail_signal.blue_output_signal = {"name": "signal-A", "type": "virtual"} - assert rail_signal.blue_output_signal == SignalID(name="signal-A", type="virtual") + assert rail_signal.blue_output_signal == SignalID( + name="signal-A", type="virtual" + ) rail_signal.blue_output_signal = None assert rail_signal.blue_output_signal == None @@ -83,6 +87,16 @@ def test_set_blue_output_signal(self): with pytest.raises(DataFormatError): rail_signal.blue_output_signal = "incorrect" + rail_signal.validate_assignment = "none" + + rail_signal.blue_output_signal = "incorrect" + assert rail_signal.blue_output_signal == "incorrect" + assert rail_signal.to_dict() == { + "name": "rail-chain-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"blue_output_signal": "incorrect"}, + } + def test_mergable_with(self): signal1 = RailChainSignal("rail-chain-signal") signal2 = RailChainSignal( @@ -120,12 +134,14 @@ def test_merge(self): signal1.merge(signal2) del signal2 - assert signal1.control_behavior == RailChainSignal.Format.ControlBehavior(**{ - "red_output_signal": "signal-A", - "orange_output_signal": "signal-B", - "green_output_signal": "signal-C", - "blue_output_signal": "signal-D", - }) + assert signal1.control_behavior == RailChainSignal.Format.ControlBehavior( + **{ + "red_output_signal": "signal-A", + "orange_output_signal": "signal-B", + "green_output_signal": "signal-C", + "blue_output_signal": "signal-D", + } + ) assert signal1.tags == {"some": "stuff"} assert signal1.to_dict()["control_behavior"] == { diff --git a/test/prototypes/test_rail_signal.py b/test/prototypes/test_rail_signal.py index af4b313..af4272f 100644 --- a/test/prototypes/test_rail_signal.py +++ b/test/prototypes/test_rail_signal.py @@ -1,8 +1,14 @@ # test_rail_signal.py +from draftsman.constants import ValidationMode from draftsman.entity import RailSignal, rail_signals, Container from draftsman.error import DataFormatError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.signatures import SignalID +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + UnknownSignalWarning, +) from collections.abc import Hashable import pytest @@ -57,24 +63,39 @@ def test_constructor_init(self): RailSignal("rail-signal", invalid_keyword="whatever") with pytest.warns(UnknownEntityWarning): RailSignal("this is not a rail signal") - + # Errors: with pytest.raises(DataFormatError): RailSignal(control_behavior="incorrect") + def test_flags(self): + assert RailSignal("rail-signal").rotatable == True + assert RailSignal("rail-signal").square == True + def test_enable_disable(self): - rail_signal = RailSignal() + rail_signal = RailSignal("rail-signal") rail_signal.enable_disable = True assert rail_signal.enable_disable == True - + rail_signal.enable_disable = None assert rail_signal.enable_disable == None with pytest.raises(DataFormatError): rail_signal.enable_disable = "incorrect" + rail_signal.validate_assignment = "none" + assert rail_signal.validate_assignment == ValidationMode.NONE + + rail_signal.enable_disable = "incorrect" + assert rail_signal.enable_disable == "incorrect" + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_close_signal": "incorrect"}, + } + def test_read_signal(self): - rail_signal = RailSignal() + rail_signal = RailSignal("rail-signal") rail_signal.read_signal = True assert rail_signal.read_signal == True @@ -84,6 +105,198 @@ def test_read_signal(self): with pytest.raises(DataFormatError): rail_signal.read_signal = "incorrect" + rail_signal.validate_assignment = "none" + assert rail_signal.validate_assignment == ValidationMode.NONE + + rail_signal.read_signal = "incorrect" + assert rail_signal.read_signal == "incorrect" + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_read_signal": "incorrect"}, + } + + def test_red_output_signal(self): + rail_signal = RailSignal("rail-signal") + assert rail_signal.red_output_signal == SignalID( + name="signal-red", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + } + + rail_signal.red_output_signal = "signal-A" + assert rail_signal.red_output_signal == SignalID( + name="signal-A", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "red_output_signal": {"name": "signal-A", "type": "virtual"} + }, + } + + rail_signal.red_output_signal = {"name": "signal-B", "type": "virtual"} + assert rail_signal.red_output_signal == SignalID( + name="signal-B", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "red_output_signal": {"name": "signal-B", "type": "virtual"} + }, + } + + with pytest.warns(UnknownSignalWarning): + rail_signal.red_output_signal = {"name": "unknown", "type": "virtual"} + assert rail_signal.red_output_signal == SignalID(name="unknown", type="virtual") + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "red_output_signal": {"name": "unknown", "type": "virtual"} + }, + } + + with pytest.raises(DataFormatError): + rail_signal.red_output_signal = "incorrect" + + rail_signal.validate_assignment = "none" + assert rail_signal.validate_assignment == ValidationMode.NONE + + rail_signal.red_output_signal = "incorrect" + assert rail_signal.red_output_signal == "incorrect" + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"red_output_signal": "incorrect"}, + } + + def test_yellow_output_signal(self): + rail_signal = RailSignal("rail-signal") + assert rail_signal.yellow_output_signal == SignalID( + name="signal-yellow", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + } + + rail_signal.yellow_output_signal = "signal-A" + assert rail_signal.yellow_output_signal == SignalID( + name="signal-A", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "orange_output_signal": {"name": "signal-A", "type": "virtual"} + }, + } + + rail_signal.yellow_output_signal = {"name": "signal-B", "type": "virtual"} + assert rail_signal.yellow_output_signal == SignalID( + name="signal-B", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "orange_output_signal": {"name": "signal-B", "type": "virtual"} + }, + } + + with pytest.warns(UnknownSignalWarning): + rail_signal.yellow_output_signal = {"name": "unknown", "type": "virtual"} + assert rail_signal.yellow_output_signal == SignalID( + name="unknown", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "orange_output_signal": {"name": "unknown", "type": "virtual"} + }, + } + + with pytest.raises(DataFormatError): + rail_signal.yellow_output_signal = "incorrect" + + rail_signal.validate_assignment = "none" + assert rail_signal.validate_assignment == ValidationMode.NONE + + rail_signal.yellow_output_signal = "incorrect" + assert rail_signal.yellow_output_signal == "incorrect" + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"orange_output_signal": "incorrect"}, + } + + def test_green_output_signal(self): + rail_signal = RailSignal("rail-signal") + assert rail_signal.green_output_signal == SignalID( + name="signal-green", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + } + + rail_signal.green_output_signal = "signal-A" + assert rail_signal.green_output_signal == SignalID( + name="signal-A", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "green_output_signal": {"name": "signal-A", "type": "virtual"} + }, + } + + rail_signal.green_output_signal = {"name": "signal-B", "type": "virtual"} + assert rail_signal.green_output_signal == SignalID( + name="signal-B", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "green_output_signal": {"name": "signal-B", "type": "virtual"} + }, + } + + with pytest.warns(UnknownSignalWarning): + rail_signal.green_output_signal = {"name": "unknown", "type": "virtual"} + assert rail_signal.green_output_signal == SignalID( + name="unknown", type="virtual" + ) + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": { + "green_output_signal": {"name": "unknown", "type": "virtual"} + }, + } + + with pytest.raises(DataFormatError): + rail_signal.green_output_signal = "incorrect" + + rail_signal.validate_assignment = "none" + assert rail_signal.validate_assignment == ValidationMode.NONE + + rail_signal.green_output_signal = "incorrect" + assert rail_signal.green_output_signal == "incorrect" + assert rail_signal.to_dict() == { + "name": "rail-signal", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"green_output_signal": "incorrect"}, + } + def test_mergable_with(self): signal1 = RailSignal("rail-signal") signal2 = RailSignal( @@ -119,11 +332,13 @@ def test_merge(self): signal1.merge(signal2) del signal2 - assert signal1.control_behavior == RailSignal.Format.ControlBehavior(**{ - "red_output_signal": "signal-A", - "orange_output_signal": "signal-B", - "green_output_signal": "signal-C", - }) + assert signal1.control_behavior == RailSignal.Format.ControlBehavior( + **{ + "red_output_signal": "signal-A", + "orange_output_signal": "signal-B", + "green_output_signal": "signal-C", + } + ) assert signal1.tags == {"some": "stuff"} assert signal1.to_dict()["control_behavior"] == { diff --git a/test/prototypes/test_reactor.py b/test/prototypes/test_reactor.py index d1e1206..73db1f4 100644 --- a/test/prototypes/test_reactor.py +++ b/test/prototypes/test_reactor.py @@ -2,7 +2,13 @@ from draftsman.entity import Reactor, reactors, Container from draftsman.error import DataFormatError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning +from draftsman.warning import ( + FuelLimitationWarning, + FuelCapacityWarning, + ItemLimitationWarning, + UnknownEntityWarning, + UnknownKeywordWarning, +) from collections.abc import Hashable import pytest @@ -18,6 +24,26 @@ def test_constructor_init(self): with pytest.warns(UnknownEntityWarning): Reactor("not a reactor") + def test_set_fuel_request(self): + reactor = Reactor("nuclear-reactor") + assert reactor.allowed_fuel_items == {"uranium-fuel-cell"} + assert reactor.total_fuel_slots == 1 + + reactor.set_item_request("uranium-fuel-cell", 50) + assert reactor.items == {"uranium-fuel-cell": 50} + + with pytest.warns(FuelCapacityWarning): + reactor.set_item_request("uranium-fuel-cell", 100) + assert reactor.items == {"uranium-fuel-cell": 100} + + with pytest.warns(FuelLimitationWarning): + reactor.items = {"coal": 50} + assert reactor.items == {"coal": 50} + + with pytest.warns(ItemLimitationWarning): + reactor.items = {"iron-plate": 100} + assert reactor.items == {"iron-plate": 100} + def test_mergable_with(self): reactor1 = Reactor("nuclear-reactor") reactor2 = Reactor("nuclear-reactor", tags={"some": "stuff"}) diff --git a/test/prototypes/test_roboport.py b/test/prototypes/test_roboport.py index 583cfe8..117d224 100644 --- a/test/prototypes/test_roboport.py +++ b/test/prototypes/test_roboport.py @@ -1,9 +1,14 @@ # test_roboport.py +from draftsman.constants import ValidationMode from draftsman.entity import Roboport, roboports, Container from draftsman.error import DataFormatError from draftsman.signatures import SignalID -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning, UnknownSignalWarning +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + UnknownSignalWarning, +) from collections.abc import Hashable import pytest @@ -121,48 +126,91 @@ def test_set_read_logistics(self): roboport.read_logistics = None assert roboport.read_logistics == None - + with pytest.raises(DataFormatError): roboport.read_logistics = "incorrect" + roboport.validate_assignment = "none" + assert roboport.validate_assignment == ValidationMode.NONE + + roboport.read_logistics = "incorrect" + assert roboport.read_logistics == "incorrect" + assert roboport.to_dict() == { + "name": "roboport", + "position": {"x": 2, "y": 2}, + "control_behavior": {"read_logistics": "incorrect"}, + } + def test_set_read_robot_stats(self): roboport = Roboport() roboport.read_robot_stats = True assert roboport.read_robot_stats == True - + roboport.read_robot_stats = None assert roboport.read_robot_stats == None with pytest.raises(DataFormatError): roboport.read_robot_stats = "incorrect" + roboport.validate_assignment = "none" + assert roboport.validate_assignment == ValidationMode.NONE + + roboport.read_robot_stats = "incorrect" + assert roboport.read_robot_stats == "incorrect" + assert roboport.to_dict() == { + "name": "roboport", + "position": {"x": 2, "y": 2}, + "control_behavior": {"read_robot_stats": "incorrect"}, + } + def test_set_available_logistics_signal(self): roboport = Roboport() roboport.available_logistic_signal = "signal-A" - assert roboport.available_logistic_signal == SignalID(name="signal-A", type="virtual") - + assert roboport.available_logistic_signal == SignalID( + name="signal-A", type="virtual" + ) + roboport.available_logistic_signal = {"name": "signal-A", "type": "virtual"} - assert roboport.available_logistic_signal == SignalID(name="signal-A", type="virtual") + assert roboport.available_logistic_signal == SignalID( + name="signal-A", type="virtual" + ) roboport.available_logistic_signal = None assert roboport.available_logistic_signal == None with pytest.warns(UnknownSignalWarning): roboport.available_logistic_signal = {"name": "unknown", "type": "item"} - assert roboport.available_logistic_signal == SignalID(name="unknown", type="item") + assert roboport.available_logistic_signal == SignalID( + name="unknown", type="item" + ) with pytest.raises(DataFormatError): roboport.available_logistic_signal = TypeError with pytest.raises(DataFormatError): roboport.available_logistic_signal = "incorrect" + roboport.validate_assignment = "none" + assert roboport.validate_assignment == ValidationMode.NONE + + roboport.available_logistic_signal = "incorrect" + assert roboport.available_logistic_signal == "incorrect" + assert roboport.to_dict() == { + "name": "roboport", + "position": {"x": 2, "y": 2}, + "control_behavior": {"available_logistic_output_signal": "incorrect"}, + } + def test_set_total_logistics_signal(self): roboport = Roboport() roboport.total_logistic_signal = "signal-B" - assert roboport.total_logistic_signal == SignalID(name="signal-B", type="virtual") + assert roboport.total_logistic_signal == SignalID( + name="signal-B", type="virtual" + ) roboport.total_logistic_signal = {"name": "signal-B", "type": "virtual"} - assert roboport.total_logistic_signal == SignalID(name="signal-B", type="virtual") + assert roboport.total_logistic_signal == SignalID( + name="signal-B", type="virtual" + ) roboport.total_logistic_signal = None assert roboport.total_logistic_signal == None @@ -176,46 +224,91 @@ def test_set_total_logistics_signal(self): with pytest.raises(DataFormatError): roboport.total_logistic_signal = "incorrect" + roboport.validate_assignment = "none" + assert roboport.validate_assignment == ValidationMode.NONE + + roboport.total_logistic_signal = "incorrect" + assert roboport.total_logistic_signal == "incorrect" + assert roboport.to_dict() == { + "name": "roboport", + "position": {"x": 2, "y": 2}, + "control_behavior": {"total_logistic_output_signal": "incorrect"}, + } + def test_set_available_construction_signal(self): roboport = Roboport() roboport.available_construction_signal = "signal-C" - assert roboport.available_construction_signal == SignalID(name="signal-C", type="virtual") + assert roboport.available_construction_signal == SignalID( + name="signal-C", type="virtual" + ) roboport.available_construction_signal = {"name": "signal-C", "type": "virtual"} - assert roboport.available_construction_signal == SignalID(name="signal-C", type="virtual") + assert roboport.available_construction_signal == SignalID( + name="signal-C", type="virtual" + ) roboport.available_construction_signal = None assert roboport.available_construction_signal == None with pytest.warns(UnknownSignalWarning): roboport.available_construction_signal = {"name": "unknown", "type": "item"} - assert roboport.available_construction_signal == SignalID(name="unknown", type="item") + assert roboport.available_construction_signal == SignalID( + name="unknown", type="item" + ) with pytest.raises(DataFormatError): roboport.available_construction_signal = TypeError with pytest.raises(DataFormatError): roboport.available_construction_signal = "incorrect" + roboport.validate_assignment = "none" + assert roboport.validate_assignment == ValidationMode.NONE + + roboport.available_construction_signal = "incorrect" + assert roboport.available_construction_signal == "incorrect" + assert roboport.to_dict() == { + "name": "roboport", + "position": {"x": 2, "y": 2}, + "control_behavior": {"available_construction_output_signal": "incorrect"}, + } + def test_set_total_construction_signal(self): roboport = Roboport() roboport.total_construction_signal = "signal-D" - assert roboport.total_construction_signal == SignalID(name="signal-D", type="virtual") - + assert roboport.total_construction_signal == SignalID( + name="signal-D", type="virtual" + ) + roboport.total_construction_signal = {"name": "signal-D", "type": "virtual"} - assert roboport.total_construction_signal == SignalID(name="signal-D", type="virtual") + assert roboport.total_construction_signal == SignalID( + name="signal-D", type="virtual" + ) roboport.total_construction_signal = None assert roboport.total_construction_signal == None with pytest.warns(UnknownSignalWarning): roboport.total_construction_signal = {"name": "unknown", "type": "item"} - assert roboport.total_construction_signal == SignalID(name="unknown", type="item") + assert roboport.total_construction_signal == SignalID( + name="unknown", type="item" + ) with pytest.raises(DataFormatError): roboport.total_construction_signal = TypeError with pytest.raises(DataFormatError): roboport.total_construction_signal = "incorrect" + roboport.validate_assignment = "none" + assert roboport.validate_assignment == ValidationMode.NONE + + roboport.total_construction_signal = "incorrect" + assert roboport.total_construction_signal == "incorrect" + assert roboport.to_dict() == { + "name": "roboport", + "position": {"x": 2, "y": 2}, + "control_behavior": {"total_construction_output_signal": "incorrect"}, + } + def test_mergable_with(self): roboport1 = Roboport("roboport") roboport2 = Roboport( @@ -257,14 +350,16 @@ def test_merge(self): roboport1.merge(roboport2) del roboport2 - assert roboport1.control_behavior == Roboport.Format.ControlBehavior(**{ - "read_logistics": True, - "read_robot_stats": True, - "available_logistic_output_signal": "signal-A", - "total_logistic_output_signal": "signal-B", - "available_construction_output_signal": "signal-C", - "total_construction_output_signal": "signal-D", - }) + assert roboport1.control_behavior == Roboport.Format.ControlBehavior( + **{ + "read_logistics": True, + "read_robot_stats": True, + "available_logistic_output_signal": "signal-A", + "total_logistic_output_signal": "signal-B", + "available_construction_output_signal": "signal-C", + "total_construction_output_signal": "signal-D", + } + ) assert roboport1.tags == {"some": "stuff"} assert roboport1.to_dict(exclude_defaults=False)["control_behavior"] == { diff --git a/test/prototypes/test_simple_entity_with_force.py b/test/prototypes/test_simple_entity_with_force.py index adc0184..a6561cf 100644 --- a/test/prototypes/test_simple_entity_with_force.py +++ b/test/prototypes/test_simple_entity_with_force.py @@ -1,7 +1,6 @@ # test_simple_entity_with_force.py -from __future__ import unicode_literals - +from draftsman.constants import Direction from draftsman.entity import SimpleEntityWithForce, simple_entities_with_force from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning @@ -26,8 +25,9 @@ def test_to_dict(self): assert entity.to_dict(exclude_defaults=False) == { "name": "simple-entity-with-force", "position": {"x": 0.5, "y": 0.5}, - "variation": 1, # Default - "tags": {} # Default + "direction": Direction.NORTH, # Default + "variation": 1, # Default + "tags": {}, # Default } entity.variation = None @@ -35,7 +35,8 @@ def test_to_dict(self): assert entity.to_dict(exclude_defaults=False) == { "name": "simple-entity-with-force", "position": {"x": 0.5, "y": 0.5}, - "tags": {} # Default + "direction": Direction.NORTH, # Default + "tags": {}, # Default } def test_power_and_circuit_flags(self): diff --git a/test/prototypes/test_simple_entity_with_owner.py b/test/prototypes/test_simple_entity_with_owner.py index 7532c63..7694ac9 100644 --- a/test/prototypes/test_simple_entity_with_owner.py +++ b/test/prototypes/test_simple_entity_with_owner.py @@ -1,9 +1,7 @@ # test_simple_entity_with_owner.py -from __future__ import unicode_literals - +from draftsman.constants import Direction from draftsman.entity import SimpleEntityWithOwner, simple_entities_with_owner -from draftsman.error import InvalidEntityError from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning import pytest @@ -27,8 +25,9 @@ def test_to_dict(self): assert entity.to_dict(exclude_defaults=False) == { "name": "simple-entity-with-owner", "position": {"x": 0.5, "y": 0.5}, - "variation": 1, # Default - "tags": {} # Default + "direction": Direction.NORTH, # Default + "variation": 1, # Default + "tags": {}, # Default } entity.variation = None @@ -36,7 +35,8 @@ def test_to_dict(self): assert entity.to_dict(exclude_defaults=False) == { "name": "simple-entity-with-owner", "position": {"x": 0.5, "y": 0.5}, - "tags": {} # Default + "direction": Direction.NORTH, # Default + "tags": {}, # Default } def test_power_and_circuit_flags(self): diff --git a/test/prototypes/test_solar_panel.py b/test/prototypes/test_solar_panel.py index 6e745cb..23106fb 100644 --- a/test/prototypes/test_solar_panel.py +++ b/test/prototypes/test_solar_panel.py @@ -12,7 +12,7 @@ def test_constructor_init(self): solar_panel = SolarPanel("solar-panel") assert solar_panel.to_dict() == { "name": "solar-panel", - "position": {"x": 1.5, "y": 1.5} + "position": {"x": 1.5, "y": 1.5}, } # Warnings diff --git a/test/prototypes/test_splitter.py b/test/prototypes/test_splitter.py index f90c700..a6bfecd 100644 --- a/test/prototypes/test_splitter.py +++ b/test/prototypes/test_splitter.py @@ -3,7 +3,11 @@ from draftsman.constants import Direction from draftsman.entity import Splitter, splitters, Container from draftsman.error import DataFormatError, InvalidItemError -from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning, UnknownItemWarning +from draftsman.warning import ( + UnknownEntityWarning, + UnknownKeywordWarning, + UnknownItemWarning, +) from collections.abc import Hashable import pytest @@ -32,7 +36,7 @@ def test_constructor_init(self): # Warnings with pytest.warns(UnknownKeywordWarning): Splitter(position=[0, 0], direction=Direction.WEST, invalid_keyword=5) - + with pytest.warns(UnknownEntityWarning): Splitter("this is not a splitter") diff --git a/test/prototypes/test_straight_rail.py b/test/prototypes/test_straight_rail.py index dddf0c1..84b43be 100644 --- a/test/prototypes/test_straight_rail.py +++ b/test/prototypes/test_straight_rail.py @@ -46,6 +46,9 @@ def test_constructor_init(self): with pytest.warns(UnknownEntityWarning): StraightRail("this is not a straight rail") + # Assert validation with no context still works + StraightRail.Format.model_validate(straight_rail._root) + def test_overlapping(self): blueprint = Blueprint() blueprint.entities.append("straight-rail") diff --git a/test/prototypes/test_train_stop.py b/test/prototypes/test_train_stop.py index 78a33af..298dc62 100644 --- a/test/prototypes/test_train_stop.py +++ b/test/prototypes/test_train_stop.py @@ -1,10 +1,15 @@ # test_train_stop.py -from draftsman.constants import Direction +from draftsman.constants import Direction, ValidationMode from draftsman.entity import TrainStop, train_stops, Container -from draftsman.error import InvalidEntityError, DataFormatError +from draftsman.error import DataFormatError from draftsman.signatures import SignalID -from draftsman.warning import GridAlignmentWarning, DirectionWarning, UnknownKeywordWarning, UnknownSignalWarning +from draftsman.warning import ( + GridAlignmentWarning, + DirectionWarning, + UnknownKeywordWarning, + UnknownSignalWarning, +) from collections.abc import Hashable import pytest @@ -101,7 +106,6 @@ def test_constructor_init(self): with pytest.raises(DataFormatError): TrainStop(station=100) - # TODO: move to validate with pytest.raises(DataFormatError): TrainStop(color="wrong") with pytest.raises(DataFormatError): @@ -117,6 +121,40 @@ def test_set_manual_trains_limit(self): with pytest.raises(DataFormatError): train_stop.manual_trains_limit = "incorrect" + def test_set_send_to_train(self): + train_stop = TrainStop() + assert train_stop.send_to_train == True + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + } + + train_stop.send_to_train = False + assert train_stop.send_to_train == False + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"send_to_train": False}, + } + + train_stop.send_to_train = None + assert train_stop.send_to_train == None + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + } + + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.send_to_train = "incorrect" + assert train_stop.send_to_train == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"send_to_train": "incorrect"}, + } + def test_set_read_from_train(self): train_stop = TrainStop() train_stop.read_from_train = True @@ -128,6 +166,17 @@ def test_set_read_from_train(self): with pytest.raises(DataFormatError): train_stop.read_from_train = "wrong" + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.read_from_train = "incorrect" + assert train_stop.read_from_train == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"read_from_train": "incorrect"}, + } + def test_set_read_stopped_train(self): train_stop = TrainStop() train_stop.read_stopped_train = False @@ -139,26 +188,55 @@ def test_set_read_stopped_train(self): with pytest.raises(DataFormatError): train_stop.read_stopped_train = "wrong" + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.read_stopped_train = "incorrect" + assert train_stop.read_stopped_train == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"read_stopped_train": "incorrect"}, + } + def test_set_train_stopped_signal(self): train_stop = TrainStop() train_stop.train_stopped_signal = "signal-A" - assert train_stop.train_stopped_signal == SignalID(name="signal-A", type="virtual") + assert train_stop.train_stopped_signal == SignalID( + name="signal-A", type="virtual" + ) train_stop.train_stopped_signal = {"name": "signal-A", "type": "virtual"} - assert train_stop.train_stopped_signal == SignalID(name="signal-A", type="virtual") + assert train_stop.train_stopped_signal == SignalID( + name="signal-A", type="virtual" + ) train_stop.train_stopped_signal = None assert train_stop.train_stopped_signal == None # Warnings with pytest.warns(UnknownSignalWarning): - train_stop.trains_count_signal = {"name": "wrong-signal", "type": "virtual"} + train_stop.train_stopped_signal = { + "name": "wrong-signal", + "type": "virtual", + } # Errors with pytest.raises(DataFormatError): - train_stop.trains_count_signal = "wrong signal" + train_stop.train_stopped_signal = "wrong signal" with pytest.raises(DataFormatError): - train_stop.trains_count_signal = TypeError + train_stop.train_stopped_signal = TypeError + + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.train_stopped_signal = "incorrect" + assert train_stop.train_stopped_signal == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"train_stopped_signal": "incorrect"}, + } def test_set_trains_limit(self): train_stop = TrainStop() @@ -171,26 +249,52 @@ def test_set_trains_limit(self): with pytest.raises(DataFormatError): train_stop.signal_limits_trains = "wrong" + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.signal_limits_trains = "incorrect" + assert train_stop.signal_limits_trains == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"set_trains_limit": "incorrect"}, + } + def test_set_trains_limit_signal(self): train_stop = TrainStop() train_stop.trains_limit_signal = "signal-A" - assert train_stop.trains_limit_signal == SignalID(name="signal-A", type="virtual") + assert train_stop.trains_limit_signal == SignalID( + name="signal-A", type="virtual" + ) train_stop.trains_limit_signal = {"name": "signal-A", "type": "virtual"} - assert train_stop.trains_limit_signal == SignalID(name="signal-A", type="virtual") + assert train_stop.trains_limit_signal == SignalID( + name="signal-A", type="virtual" + ) train_stop.trains_limit_signal = None assert train_stop.trains_limit_signal == None # Warnings with pytest.warns(UnknownSignalWarning): - train_stop.trains_count_signal = {"name": "wrong-signal", "type": "virtual"} + train_stop.trains_limit_signal = {"name": "wrong-signal", "type": "virtual"} # Errors with pytest.raises(DataFormatError): - train_stop.trains_count_signal = "wrong signal" + train_stop.trains_limit_signal = "wrong signal" with pytest.raises(DataFormatError): - train_stop.trains_count_signal = TypeError + train_stop.trains_limit_signal = TypeError + + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.trains_limit_signal = "incorrect" + assert train_stop.trains_limit_signal == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"trains_limit_signal": "incorrect"}, + } def test_set_read_trains_count(self): train_stop = TrainStop() @@ -203,13 +307,28 @@ def test_set_read_trains_count(self): with pytest.raises(DataFormatError): train_stop.read_trains_count = "wrong" + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.read_trains_count = "incorrect" + assert train_stop.read_trains_count == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"read_trains_count": "incorrect"}, + } + def test_set_trains_count_signal(self): train_stop = TrainStop() train_stop.trains_count_signal = "signal-A" - assert train_stop.trains_count_signal == SignalID(name="signal-A", type="virtual") + assert train_stop.trains_count_signal == SignalID( + name="signal-A", type="virtual" + ) train_stop.trains_count_signal = {"name": "signal-A", "type": "virtual"} - assert train_stop.trains_count_signal == SignalID(name="signal-A", type="virtual") + assert train_stop.trains_count_signal == SignalID( + name="signal-A", type="virtual" + ) train_stop.trains_count_signal = None assert train_stop.trains_count_signal == None @@ -224,6 +343,17 @@ def test_set_trains_count_signal(self): with pytest.raises(DataFormatError): train_stop.trains_count_signal = TypeError + train_stop.validate_assignment = "none" + assert train_stop.validate_assignment == ValidationMode.NONE + + train_stop.trains_count_signal = "incorrect" + assert train_stop.trains_count_signal == "incorrect" + assert train_stop.to_dict() == { + "name": "train-stop", + "position": {"x": 1, "y": 1}, + "control_behavior": {"trains_count_signal": "incorrect"}, + } + def test_power_and_circuit_flags(self): for name in train_stops: train_stop = TrainStop(name) @@ -232,6 +362,11 @@ def test_power_and_circuit_flags(self): assert train_stop.circuit_connectable == True assert train_stop.dual_circuit_connectable == False + def test_double_grid_aligned(self): + for name in train_stops: + train_stop = TrainStop(name) + assert train_stop.double_grid_aligned == True + def test_mergable_with(self): stop1 = TrainStop("train-stop") stop2 = TrainStop( diff --git a/test/prototypes/test_transport_belt.py b/test/prototypes/test_transport_belt.py index 229e9c4..acde7f5 100644 --- a/test/prototypes/test_transport_belt.py +++ b/test/prototypes/test_transport_belt.py @@ -1,6 +1,6 @@ # test_transport_belt.py -from draftsman.constants import Direction, ReadMode +from draftsman.constants import Direction, ReadMode, ValidationMode from draftsman.entity import TransportBelt, transport_belts, Container from draftsman.error import InvalidEntityError, DataFormatError from draftsman.warning import UnknownEntityWarning, UnknownKeywordWarning @@ -90,6 +90,112 @@ def test_constructor_init(self): control_behavior=["also", "very", "wrong"], ) + def test_set_enable_disable(self): + belt = TransportBelt("transport-belt") + assert belt.enable_disable == None + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + } + + belt.enable_disable = True + assert belt.enable_disable == True + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_enable_disable": True}, + } + + belt.enable_disable = False + assert belt.enable_disable == False + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_enable_disable": False}, + } + + with pytest.raises(DataFormatError): + belt.enable_disable = "incorrect" + + belt.validate_assignment = "none" + assert belt.validate_assignment == ValidationMode.NONE + belt.enable_disable = "incorrect" + assert belt.enable_disable == "incorrect" + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_enable_disable": "incorrect"}, + } + + def test_set_read_contents(self): + belt = TransportBelt("transport-belt") + assert belt.read_contents == None + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + } + + belt.read_contents = True + assert belt.read_contents == True + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_read_hand_contents": True}, + } + + with pytest.raises(DataFormatError): + belt.read_contents = "incorrect" + assert belt.read_contents == True + + belt.validate_assignment = "none" + assert belt.validate_assignment == ValidationMode.NONE + + belt.read_contents = "incorrect" + assert belt.read_contents == "incorrect" + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_read_hand_contents": "incorrect"}, + } + + def test_set_read_mode(self): + belt = TransportBelt("transport-belt") + assert belt.read_mode == None + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + } + + belt.read_mode = ReadMode.HOLD + assert belt.read_mode == ReadMode.HOLD + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_contents_read_mode": ReadMode.HOLD}, + } + + belt.read_mode = None + assert belt.read_mode == None + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + } + + with pytest.raises(DataFormatError): + belt.read_mode = "incorrect" + assert belt.read_mode == None + + belt.validate_assignment = "none" + assert belt.validate_assignment == ValidationMode.NONE + + belt.read_mode = "incorrect" + assert belt.read_mode == "incorrect" + assert belt.to_dict() == { + "name": "transport-belt", + "position": {"x": 0.5, "y": 0.5}, + "control_behavior": {"circuit_contents_read_mode": "incorrect"}, + } + def test_power_and_circuit_flags(self): for transport_belt in transport_belts: belt = TransportBelt(transport_belt) diff --git a/test/prototypes/test_turret.py b/test/prototypes/test_turret.py index 1d8589b..2cbfb09 100644 --- a/test/prototypes/test_turret.py +++ b/test/prototypes/test_turret.py @@ -21,6 +21,14 @@ def test_constructor_init(self): with pytest.warns(UnknownEntityWarning): Turret("this is not a turret") + def test_flags(self): + turret = Turret("gun-turret") + assert turret.rotatable == True + assert turret.square == True + turret = Turret("flamethrower-turret") + assert turret.rotatable == True + assert turret.square == False + def test_mergable_with(self): turret1 = Turret("gun-turret") turret2 = Turret("gun-turret", tags={"some": "stuff"}) diff --git a/test/prototypes/test_underground_belt.py b/test/prototypes/test_underground_belt.py index d47f3b7..349caae 100644 --- a/test/prototypes/test_underground_belt.py +++ b/test/prototypes/test_underground_belt.py @@ -15,6 +15,12 @@ class TestUndergroundBelt: def test_constructor_init(self): # Valid + underground_belt = UndergroundBelt("underground-belt") + assert underground_belt.to_dict() == { + "name": "underground-belt", + "position": {"x": 0.5, "y": 0.5}, + } + underground_belt = UndergroundBelt( "underground-belt", direction=Direction.EAST, diff --git a/test/prototypes/test_wall.py b/test/prototypes/test_wall.py index 2142c92..1b9dbb6 100644 --- a/test/prototypes/test_wall.py +++ b/test/prototypes/test_wall.py @@ -3,7 +3,12 @@ from draftsman.entity import Wall, Container from draftsman.error import DataFormatError from draftsman.signatures import SignalID -from draftsman.warning import MalformedSignalWarning, UnknownEntityWarning, UnknownKeywordWarning, UnknownSignalWarning +from draftsman.warning import ( + MalformedSignalWarning, + UnknownEntityWarning, + UnknownKeywordWarning, + UnknownSignalWarning, +) from draftsman.constants import ValidationMode as vm from collections.abc import Hashable @@ -20,14 +25,14 @@ def test_constructor_init(self): wall = Wall("stone-wall", validate=vm.NONE) assert wall.to_dict() == { "name": "stone-wall", - "position": {"x": 0.5, "y": 0.5} + "position": {"x": 0.5, "y": 0.5}, } # Unkown entity wall = Wall("unknown-wall", validate=vm.NONE) assert wall.to_dict() == { "name": "unknown-wall", - "position": {"x": 0.0, "y": 0.0} + "position": {"x": 0.0, "y": 0.0}, } # Unknown keyword @@ -35,17 +40,13 @@ def test_constructor_init(self): assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "unused_keyword": "whatever" + "unused_keyword": "whatever", } # Import from correct dictionary wall = Wall( "stone-wall", - connections={ - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + connections={"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, control_behavior={ "circuit_open_gate": True, "circuit_condition": { @@ -54,28 +55,24 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": "signal-B" + "output_signal": "signal-B", }, tags={"some": "stuff"}, - validate=vm.NONE + validate=vm.NONE, ) assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "connections": { - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + "connections": {"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, "control_behavior": { - "circuit_open_gate": True, # Default included because not a known internal type + # "circuit_open_gate": True, # Default "circuit_condition": { - "first_signal": "signal-A", - "comparator": "<", + "first_signal": {"name": "signal-A", "type": "virtual"}, + # "comparator": "<", # Default "constant": 100, }, "circuit_read_sensor": True, - "output_signal": "signal-B" + "output_signal": {"name": "signal-B", "type": "virtual"}, }, "tags": {"some": "stuff"}, } @@ -91,7 +88,7 @@ def test_constructor_init(self): "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, "connections": "incorrect", - "control_behavior": "incorrect" + "control_behavior": "incorrect", } # =================== @@ -102,14 +99,14 @@ def test_constructor_init(self): wall = Wall("stone-wall", validate=vm.MINIMUM) assert wall.to_dict() == { "name": "stone-wall", - "position": {"x": 0.5, "y": 0.5} + "position": {"x": 0.5, "y": 0.5}, } # Unkown entity wall = Wall("unknown-wall", validate=vm.MINIMUM) assert wall.to_dict() == { "name": "unknown-wall", - "position": {"x": 0.0, "y": 0.0} + "position": {"x": 0.0, "y": 0.0}, } # Unknown keyword @@ -117,17 +114,13 @@ def test_constructor_init(self): assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "unused_keyword": "whatever" + "unused_keyword": "whatever", } # Import from correct dictionary wall = Wall( "stone-wall", - connections={ - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + connections={"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, control_behavior={ "circuit_open_gate": True, "circuit_condition": { @@ -136,19 +129,15 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": "signal-B" + "output_signal": "signal-B", }, tags={"some": "stuff"}, - validate=vm.MINIMUM + validate=vm.MINIMUM, ) assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "connections": { - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + "connections": {"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, "control_behavior": { # "circuit_open_gate": True, # Default excluded because stripped "circuit_condition": { @@ -157,7 +146,7 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": {"name": "signal-B", "type": "virtual"} + "output_signal": {"name": "signal-B", "type": "virtual"}, }, "tags": {"some": "stuff"}, } @@ -179,7 +168,7 @@ def test_constructor_init(self): wall = Wall("stone-wall", validate=vm.STRICT) assert wall.to_dict() == { "name": "stone-wall", - "position": {"x": 0.5, "y": 0.5} + "position": {"x": 0.5, "y": 0.5}, } # Unkown entity @@ -187,7 +176,7 @@ def test_constructor_init(self): wall = Wall("unknown-wall", validate=vm.STRICT) assert wall.to_dict() == { "name": "unknown-wall", - "position": {"x": 0.0, "y": 0.0} + "position": {"x": 0.0, "y": 0.0}, } # Unknown keyword @@ -196,17 +185,13 @@ def test_constructor_init(self): assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "unused_keyword": "whatever" + "unused_keyword": "whatever", } # Import from correct dictionary wall = Wall( "stone-wall", - connections={ - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + connections={"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, control_behavior={ "circuit_open_gate": True, "circuit_condition": { @@ -215,19 +200,15 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": "signal-B" + "output_signal": "signal-B", }, tags={"some": "stuff"}, - validate=vm.STRICT + validate=vm.STRICT, ) assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "connections": { - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + "connections": {"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, "control_behavior": { # "circuit_open_gate": True, # Default "circuit_condition": { @@ -236,7 +217,7 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": {"name": "signal-B", "type": "virtual"} + "output_signal": {"name": "signal-B", "type": "virtual"}, }, "tags": {"some": "stuff"}, } @@ -258,25 +239,21 @@ def test_constructor_init(self): wall = Wall("stone-wall", validate=vm.PEDANTIC) assert wall.to_dict() == { "name": "stone-wall", - "position": {"x": 0.5, "y": 0.5} + "position": {"x": 0.5, "y": 0.5}, } # Unkown entity - with pytest.raises(DataFormatError): + with pytest.warns(UnknownEntityWarning): wall = Wall("unknown-wall", validate=vm.PEDANTIC) # Unknown keyword - with pytest.raises(DataFormatError): + with pytest.warns(UnknownKeywordWarning): wall = Wall("stone-wall", unused_keyword="whatever", validate=vm.PEDANTIC) # Import from correct dictionary wall = Wall( "stone-wall", - connections={ - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + connections={"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, control_behavior={ "circuit_open_gate": True, "circuit_condition": { @@ -285,19 +262,15 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": "signal-B" + "output_signal": "signal-B", }, tags={"some": "stuff"}, - validate=vm.PEDANTIC + validate=vm.PEDANTIC, ) assert wall.to_dict() == { "name": "stone-wall", "position": {"x": 0.5, "y": 0.5}, - "connections": { - "1": { - "red": [{"entity_id": 2, "circuit_id": 1}] - } - }, + "connections": {"1": {"red": [{"entity_id": 2, "circuit_id": 1}]}}, "control_behavior": { # "circuit_open_gate": True, # Default "circuit_condition": { @@ -306,7 +279,7 @@ def test_constructor_init(self): "constant": 100, }, "circuit_read_sensor": True, - "output_signal": {"name": "signal-B", "type": "virtual"} + "output_signal": {"name": "signal-B", "type": "virtual"}, }, "tags": {"some": "stuff"}, } @@ -335,7 +308,7 @@ def test_set_enable_disable(self): wall.enable_disable = True assert wall.enable_disable == True assert wall.control_behavior.circuit_open_gate == True - + # Incorrect type wall.enable_disable = "incorrect" assert wall.enable_disable == "incorrect" @@ -355,7 +328,7 @@ def test_set_enable_disable(self): wall.enable_disable = True assert wall.enable_disable == True assert wall.control_behavior.circuit_open_gate == True - + # Incorrect type with pytest.raises(DataFormatError): wall.enable_disable = "incorrect" @@ -374,7 +347,7 @@ def test_set_enable_disable(self): wall.enable_disable = True assert wall.enable_disable == True assert wall.control_behavior.circuit_open_gate == True - + # Incorrect type with pytest.raises(DataFormatError): wall.enable_disable = "incorrect" @@ -393,12 +366,11 @@ def test_set_enable_disable(self): wall.enable_disable = True assert wall.enable_disable == True assert wall.control_behavior.circuit_open_gate == True - + # Incorrect type with pytest.raises(DataFormatError): wall.enable_disable = "incorrect" - def test_set_read_gate(self): # ======================== # No assignment validation @@ -413,7 +385,7 @@ def test_set_read_gate(self): # bool wall.read_gate = True assert wall.read_gate == True - + # Incorrect type wall.read_gate = "incorrect" assert wall.read_gate == "incorrect" @@ -469,8 +441,6 @@ def test_set_read_gate(self): with pytest.raises(DataFormatError): wall.read_gate = "incorrect" - - def test_set_output_signal(self): # ======================== # No assignment validation @@ -484,10 +454,8 @@ def test_set_output_signal(self): # Known string wall.output_signal = "signal-A" assert wall.output_signal == "signal-A" - assert wall.to_dict()["control_behavior"] == { - "output_signal": "signal-A" - } - + assert wall.to_dict()["control_behavior"] == {"output_signal": "signal-A"} + # Known dict wall.output_signal = {"name": "signal-A", "type": "virtual"} assert wall.output_signal == {"name": "signal-A", "type": "virtual"} @@ -532,7 +500,7 @@ def test_set_output_signal(self): # Unknown string with pytest.raises(DataFormatError): wall.output_signal = "unknown-signal" - + # Unknown dict wall.output_signal = {"name": "unknown-signal", "type": "virtual"} assert wall.output_signal == SignalID(name="unknown-signal", type="virtual") @@ -594,20 +562,22 @@ def test_set_output_signal(self): assert wall.output_signal == SignalID(name="signal-A", type="virtual") # Known dict but malformed - with pytest.raises(DataFormatError): + with pytest.warns(MalformedSignalWarning): wall.output_signal = {"name": "signal-A", "type": "fluid"} + assert wall.output_signal == SignalID(name="signal-A", type="fluid") # Unknown string with pytest.raises(DataFormatError): wall.output_signal = "unknown-signal" # Unknown dict - with pytest.raises(DataFormatError): + with pytest.warns(UnknownSignalWarning): wall.output_signal = {"name": "unknown-signal", "type": "virtual"} + assert wall.output_signal == SignalID(name="unknown-signal", type="virtual") # Incorrect Type with pytest.raises(DataFormatError): - wall.output_signal = DataFormatError + wall.output_signal = DataFormatError def test_mergable_with(self): wall1 = Wall("stone-wall") diff --git a/test/test_blueprint.py b/test/test_blueprint.py index 3fda9f7..31cb546 100644 --- a/test/test_blueprint.py +++ b/test/test_blueprint.py @@ -16,7 +16,7 @@ Direction, Orientation, WaitConditionType, - WaitConditionCompareType, + ValidationMode, ) from draftsman.entity import Container, ElectricPole, new_entity from draftsman.tile import Tile @@ -34,7 +34,7 @@ InvalidAssociationError, InvalidSignalError, ) -from draftsman.signatures import Color, Icons +from draftsman.signatures import Color, Icon from draftsman.utils import encode_version, AABB from draftsman.warning import ( DraftsmanWarning, @@ -87,6 +87,17 @@ def test_constructor(self): blueprint = Blueprint(example) assert blueprint.to_dict() == example + blueprint = Blueprint(example, validate="none") + assert blueprint.to_dict() == example + + broken_example = {"blueprint": {"item": "blueprint", "version": "incorrect"}} + + with pytest.raises(DataFormatError): + Blueprint(broken_example) + + blueprint = Blueprint(broken_example, validate="none") + assert blueprint.to_dict() == broken_example + # # TypeError # with self.assertRaises(TypeError): # Blueprint(TypeError) @@ -237,19 +248,19 @@ def test_set_icons(self): blueprint = Blueprint() # Single Icon blueprint.set_icons("signal-A") - assert blueprint.icons == Icons(root=[ - {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} - ]) - assert blueprint["blueprint"]["icons"] == Icons(root=[ - {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} - ]) + assert blueprint.icons == [ + Icon(**{"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}) + ] + assert blueprint["blueprint"]["icons"] == [ + Icon(**{"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}) + ] # Multiple Icon blueprint.set_icons("signal-A", "signal-B", "signal-C") - assert blueprint["blueprint"]["icons"] == Icons(root=[ - {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}, - {"signal": {"name": "signal-B", "type": "virtual"}, "index": 2}, - {"signal": {"name": "signal-C", "type": "virtual"}, "index": 3}, - ]) + assert blueprint["blueprint"]["icons"] == [ + Icon(**{"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}), + Icon(**{"signal": {"name": "signal-B", "type": "virtual"}, "index": 2}), + Icon(**{"signal": {"name": "signal-C", "type": "virtual"}, "index": 3}), + ] # Raw signal dicts: # TODO: reimplement @@ -278,6 +289,22 @@ def test_set_icons(self): # with pytest.raises(TypeError): # blueprint.set_icons({"incorrectly": "formatted"}) + with pytest.raises(DataFormatError): + blueprint.icons = "incorrect" + + blueprint.validate_assignment = "none" + assert blueprint.validate_assignment == ValidationMode.NONE + + blueprint.icons = "incorrect" + assert blueprint.icons == "incorrect" + assert blueprint.to_dict() == { + "blueprint": { + "item": "blueprint", + "version": encode_version(*__factorio_version_info__), + "icons": "incorrect", + } + } + # ========================================================================= def test_set_description(self): @@ -2600,6 +2627,7 @@ def test_find_trains_filtered(self): schedule.append_stop("Station A", WaitCondition(WaitConditionType.FULL_CARGO)) schedule.append_stop("Station B", WaitCondition(WaitConditionType.EMPTY_CARGO)) trains = bp.find_trains_filtered(schedule=schedule) + assert schedule.stops == bp.schedules[0].stops assert len(trains) == 1 # Trains that follow a different schedule @@ -2735,7 +2763,7 @@ def test_flip(self): # ========================================================================= - def test_equals(self): + def test_eq(self): blueprint1 = Blueprint() # Trivial case @@ -2753,3 +2781,6 @@ def test_equals(self): entity = Container("wooden-chest") assert blueprint1 != entity + + def test_json_schema(self): + Blueprint.json_schema() diff --git a/test/test_blueprint_book.py b/test/test_blueprint_book.py index 389b9f4..4b0ebc3 100644 --- a/test/test_blueprint_book.py +++ b/test/test_blueprint_book.py @@ -10,9 +10,9 @@ IncorrectBlueprintTypeError, DataFormatError, ) -from draftsman.signatures import Color, Icons +from draftsman.signatures import Color, Icon from draftsman.utils import encode_version, string_to_JSON -from draftsman.warning import DraftsmanWarning, UnknownSignalWarning +from draftsman.warning import DraftsmanWarning, IndexWarning, UnknownSignalWarning import pytest @@ -74,6 +74,9 @@ def test_delitem(self): assert blueprint_book.blueprints.data == [] + def test_repr(self): + assert repr(BlueprintBook().blueprints) == "[]" + class TestBlueprintBook: def test_constructor(self): @@ -81,7 +84,7 @@ def test_constructor(self): assert blueprint_book.to_dict() == { "blueprint_book": { - "active_index": 0, + # "active_index": 0, "item": "blueprint-book", "version": encode_version(*__factorio_version_info__), } @@ -97,7 +100,7 @@ def test_constructor(self): blueprint_book = BlueprintBook(example) assert blueprint_book.to_dict() == { "blueprint_book": { - "active_index": 0, + # "active_index": 0, "item": "blueprint-book", "version": encode_version(*__factorio_version_info__), } @@ -108,7 +111,7 @@ def test_constructor(self): ) assert blueprint_book.to_dict() == { "blueprint_book": { - "active_index": 0, + # "active_index": 0, "item": "blueprint-book", "version": encode_version(1, 1, 53, 0), } @@ -120,7 +123,7 @@ def test_constructor(self): ) assert blueprint_book.to_dict() == { "blueprint_book": { - "active_index": 0, + # "active_index": 0, "item": "blueprint-book", "icons": [{"index": 1, "signal": {"name": "wood", "type": "item"}}], "version": encode_version(1, 1, 59, 0), @@ -134,7 +137,7 @@ def test_constructor(self): # print(blueprint_book.version_tuple()) assert blueprint_book.to_dict() == { "blueprint_book": { - "active_index": 0, + # "active_index": 0, "item": "blueprint-book", "label": "A name.", "description": "A description.", @@ -142,18 +145,47 @@ def test_constructor(self): } } - # # Incorrect constructor - # with self.assertRaises(TypeError): - # BlueprintBook(TypeError) + # Incorrect constructor + with pytest.raises(DataFormatError): + BlueprintBook(DataFormatError) - # # Valid blueprint string, but wrong type - # with self.assertRaises(IncorrectBlueprintTypeError): - # BlueprintBook( - # "0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJCEtNRKkstKs7Mz1OyMrIwNDG3NDI3NTI0s7A0q60FAHmRE1c=" - # ) + # Valid blueprint string, but wrong type + with pytest.raises(IncorrectBlueprintTypeError): + BlueprintBook( + "0eNqrVkrKKU0tKMrMK1GyqlbKLEnNVbJCEtNRKkstKs7Mz1OyMrIwNDG3NDI3NTI0s7A0q60FAHmRE1c=" + ) - def test_load_from_string(self): - pass + def test_validate(self): + incorrect_data = { + "blueprint_book": { + "item": "very-wrong", # This is ignored; TODO: is this a good idea? + "version": "wrong", + } + } + bpb = BlueprintBook(incorrect_data, validate="none") + assert bpb.version == "wrong" + assert bpb.to_dict() == { + "blueprint_book": {"item": "blueprint-book", "version": "wrong"} + } + + # Issue Errors + with pytest.raises(DataFormatError): + bpb.validate().reissue_all() + + # Fix + bpb.version = (1, 0) + assert bpb.version == 281474976710656 + bpb.validate().reissue_all() # Nothing + + bpb.validate_assignment = "none" + bpb.icons = [{"signal": {"name": "unknown", "type": "item"}, "index": 0}] + + # No warnings + bpb.validate(mode="minimum").reissue_all() + + # Issue warnings + with pytest.warns(UnknownSignalWarning): + bpb.validate(mode="strict").reissue_all() def test_setup(self): blueprint_book = BlueprintBook() @@ -170,7 +202,7 @@ def test_setup(self): "blueprint_book": { "label": "a label", "label_color": {"r": 50, "g": 50, "b": 50}, - "active_index": 0, + # "active_index": 0, "item": "blueprint-book", "version": encode_version(*__factorio_version_info__), } @@ -189,7 +221,7 @@ def test_set_label(self): "blueprint_book": { "item": "blueprint-book", "label": "testing The LABEL", - "active_index": 0, + # "active_index": 0, "version": encode_version(1, 1, 54, 0), } } @@ -198,7 +230,7 @@ def test_set_label(self): assert blueprint_book.to_dict() == { "blueprint_book": { "item": "blueprint-book", - "active_index": 0, + # "active_index": 0, "version": encode_version(1, 1, 54, 0), } } @@ -214,7 +246,7 @@ def test_set_label_color(self): "blueprint_book": { "item": "blueprint-book", "label_color": {"r": 0.5, "g": 0.1, "b": 0.5}, - "active_index": 0, + # "active_index": 0, "version": encode_version(1, 1, 54, 0), } } @@ -224,7 +256,7 @@ def test_set_label_color(self): "blueprint_book": { "item": "blueprint-book", "label_color": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 0.25}, - "active_index": 0, + # "active_index": 0, "version": encode_version(1, 1, 54, 0), } } @@ -234,7 +266,7 @@ def test_set_label_color(self): assert blueprint_book.to_dict() == { "blueprint_book": { "item": "blueprint-book", - "active_index": 0, + # "active_index": 0, "version": encode_version(1, 1, 54, 0), } } @@ -250,31 +282,31 @@ def test_set_icons(self): blueprint_book = BlueprintBook() # Single Icon blueprint_book.set_icons("signal-A") - assert blueprint_book.icons == Icons(root=[ - {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} - ]) - assert blueprint_book["blueprint_book"]["icons"] == Icons(root=[ - {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1} - ]) + assert blueprint_book.icons == [ + Icon(**{"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}) + ] + assert blueprint_book["blueprint_book"]["icons"] == [ + Icon(**{"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}) + ] # Multiple Icons blueprint_book.set_icons("signal-A", "signal-B", "signal-C") - assert blueprint_book["blueprint_book"]["icons"] == Icons(root=[ - {"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}, - {"signal": {"name": "signal-B", "type": "virtual"}, "index": 2}, - {"signal": {"name": "signal-C", "type": "virtual"}, "index": 3}, - ]) + assert blueprint_book["blueprint_book"]["icons"] == [ + Icon(**{"signal": {"name": "signal-A", "type": "virtual"}, "index": 1}), + Icon(**{"signal": {"name": "signal-B", "type": "virtual"}, "index": 2}), + Icon(**{"signal": {"name": "signal-C", "type": "virtual"}, "index": 3}), + ] # Raw signal dicts blueprint_book.icons = [] with pytest.raises(DataFormatError): blueprint_book.set_icons({"name": "some-signal", "type": "some-type"}) - assert blueprint_book.icons == Icons(root=[]) + assert blueprint_book.icons == [] with pytest.warns(UnknownSignalWarning): blueprint_book.set_icons({"name": "some-signal", "type": "virtual"}) - assert blueprint_book["blueprint_book"]["icons"] == Icons(root=[ - {"signal": {"name": "some-signal", "type": "virtual"}, "index": 1} - ]) + assert blueprint_book["blueprint_book"]["icons"] == [ + Icon(**{"signal": {"name": "some-signal", "type": "virtual"}, "index": 1}) + ] # None blueprint_book.icons = None @@ -282,7 +314,7 @@ def test_set_icons(self): assert blueprint_book.to_dict() == { "blueprint_book": { "item": "blueprint-book", - "active_index": 0, + # "active_index": 0, # Default "version": encode_version(*__factorio_version_info__), } } @@ -303,18 +335,13 @@ def test_set_active_index(self): assert blueprint_book.active_index == 1 blueprint_book.active_index = None - assert blueprint_book.active_index == 0 - - # Warnings: TODO - # with pytest.warns(IndexWarning): - # blueprint_book.active_index = 10 + assert blueprint_book.active_index == None # Errors - # with pytest.raises(TypeError): - # blueprint_book.active_index = "incorrect" - - # with pytest.raises(IndexError): - # blueprint_book.active_index = -1 + with pytest.raises(DataFormatError): + blueprint_book.active_index = -1 + with pytest.raises(DataFormatError): + blueprint_book.active_index = "incorrect" def test_set_version(self): blueprint_book = BlueprintBook() @@ -324,7 +351,7 @@ def test_set_version(self): blueprint_book.version = None assert blueprint_book.version == None assert blueprint_book.to_dict() == { - "blueprint_book": {"item": "blueprint-book", "active_index": 0} + "blueprint_book": {"item": "blueprint-book"} } with pytest.raises(TypeError): @@ -350,7 +377,7 @@ def test_set_blueprints(self): assert blueprint_book.to_dict() == { "blueprint_book": { "item": "blueprint-book", - "active_index": 0, + # "active_index": 0, # Default "blueprints": [ { "index": 0, @@ -364,7 +391,43 @@ def test_set_blueprints(self): "index": 1, "blueprint_book": { "item": "blueprint-book", - "active_index": 0, + # "active_index": 0, # Default + "version": encode_version(*__factorio_version_info__), + }, + }, + { + "index": 2, + "blueprint": { + "item": "blueprint", + "label": "B", + "version": encode_version(*__factorio_version_info__), + }, + }, + ], + "version": encode_version(*__factorio_version_info__), + } + } + + new_blueprint_book = BlueprintBook() + new_blueprint_book.blueprints = blueprint_book.blueprints + assert new_blueprint_book.to_dict() == { + "blueprint_book": { + "item": "blueprint-book", + # "active_index": 0, # Default + "blueprints": [ + { + "index": 0, + "blueprint": { + "item": "blueprint", + "label": "A", + "version": encode_version(*__factorio_version_info__), + }, + }, + { + "index": 1, + "blueprint_book": { + "item": "blueprint-book", + # "active_index": 0, # Default "version": encode_version(*__factorio_version_info__), }, }, @@ -388,6 +451,77 @@ def test_set_blueprints(self): with pytest.raises(DataFormatError): blueprint_book.blueprints = TypeError + def test_custom_index(self): + blueprint_book = BlueprintBook() + + blueprint = Blueprint() + blueprint_book.blueprints.append(blueprint) + blueprint = Blueprint() + blueprint.index = 5 + blueprint_book.blueprints.append(blueprint) + + blueprint_book.blueprints[1].index = 5 + assert len(blueprint_book.blueprints) == 2 + + assert blueprint_book.to_dict() == { + "blueprint_book": { + "item": "blueprint-book", + "blueprints": [ + { + "index": 0, + "blueprint": { + "item": "blueprint", + "version": encode_version(*__factorio_version_info__), + }, + }, + { + "index": 5, + "blueprint": { + "item": "blueprint", + "version": encode_version(*__factorio_version_info__), + }, + }, + ], + "version": encode_version(*__factorio_version_info__), + } + } + + blueprint = Blueprint(validate_assignment="none") + blueprint.index = "incorrect" + assert blueprint.index == "incorrect" + blueprint_book.blueprints.append(blueprint) + + assert len(blueprint_book.blueprints) == 3 + assert blueprint_book.to_dict() == { + "blueprint_book": { + "item": "blueprint-book", + "blueprints": [ + { + "index": 0, + "blueprint": { + "item": "blueprint", + "version": encode_version(*__factorio_version_info__), + }, + }, + { + "index": 5, + "blueprint": { + "item": "blueprint", + "version": encode_version(*__factorio_version_info__), + }, + }, + { + "index": "incorrect", + "blueprint": { + "item": "blueprint", + "version": encode_version(*__factorio_version_info__), + }, + }, + ], + "version": encode_version(*__factorio_version_info__), + } + } + def test_version_tuple(self): blueprint_book = BlueprintBook() assert blueprint_book.version_tuple() == __factorio_version_info__ diff --git a/test/test_blueprintable.py b/test/test_blueprintable.py index 3e7bccf..bb84781 100644 --- a/test/test_blueprintable.py +++ b/test/test_blueprintable.py @@ -1,7 +1,11 @@ # test_blueprintable.py from draftsman.blueprintable import * -from draftsman.error import DataFormatError, MalformedBlueprintStringError, IncorrectBlueprintTypeError +from draftsman.error import ( + DataFormatError, + MalformedBlueprintStringError, + IncorrectBlueprintTypeError, +) from draftsman.utils import JSON_to_string import pytest diff --git a/test/test_deconstruction_planner.py b/test/test_deconstruction_planner.py index e94ddb9..dfc7c76 100644 --- a/test/test_deconstruction_planner.py +++ b/test/test_deconstruction_planner.py @@ -2,11 +2,11 @@ from draftsman import __factorio_version_info__ from draftsman.classes.deconstruction_planner import DeconstructionPlanner -from draftsman.constants import FilterMode, TileSelectionMode +from draftsman.constants import FilterMode, TileSelectionMode, ValidationMode from draftsman.error import DataFormatError -from draftsman.signatures import EntityFilter, TileFilter +from draftsman.signatures import EntityFilter, Icon, TileFilter from draftsman.utils import encode_version -from draftsman.warning import DraftsmanWarning +from draftsman.warning import DraftsmanWarning, UnknownEntityWarning, UnknownTileWarning import pytest @@ -54,6 +54,50 @@ def test_constructor(self): {"deconstruction_planner": {"something": "incorrect"}, "index": 1} ) + invalid_data = { + "deconstruction_planner": { + "item": "deconstruction-planner", + "version": "incorrect", + "settings": {"description": 100}, + } + } + with pytest.raises(DataFormatError): + DeconstructionPlanner(invalid_data) + + broken_planner = DeconstructionPlanner(invalid_data, validate="none") + assert broken_planner.version == "incorrect" + assert broken_planner.description == 100 + print(broken_planner._root) + # Fix + broken_planner.version = __factorio_version_info__ + broken_planner.description = "an actual string" + broken_planner.validate().reissue_all() # No errors or warnings + + def test_entity_filter_count(self): + assert DeconstructionPlanner().entity_filter_count == 30 + + def test_tile_filter_count(self): + assert DeconstructionPlanner().tile_filter_count == 30 + + def test_set_icons(self): + decon_planner = DeconstructionPlanner() + assert decon_planner.icons == None + + decon_planner.icons = [ + "signal-A", + {"index": 2, "signal": {"name": "signal-B", "type": "virtual"}}, + ] + assert decon_planner.icons == [ + Icon(signal="signal-A", index=1), + Icon(signal="signal-B", index=2), + ] + + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + + decon_planner.icons = "incorrect" + assert decon_planner.icons == "incorrect" + def test_set_entity_filter_mode(self): decon_planner = DeconstructionPlanner() @@ -79,6 +123,16 @@ def test_set_entity_filter_mode(self): with pytest.raises(DataFormatError): decon_planner.entity_filter_mode = "incorrect" + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + decon_planner.entity_filter_mode = "incorrect" + assert decon_planner.entity_filter_mode == "incorrect" + assert decon_planner.to_dict()["deconstruction_planner"] == { + "item": "deconstruction-planner", + "version": encode_version(*__factorio_version_info__), + "settings": {"entity_filter_mode": "incorrect"}, + } + def test_set_entity_filters(self): decon_planner = DeconstructionPlanner() @@ -99,16 +153,42 @@ def test_set_entity_filters(self): EntityFilter(**{"name": "fast-transport-belt", "index": 2}), ] + # Test unknown entity + with pytest.warns(UnknownEntityWarning): + decon_planner.entity_filters = ["unknown-thingy"] + assert decon_planner.entity_filters == [ + EntityFilter(**{"name": "unknown-thingy", "index": 1}), + ] + + with pytest.raises(DataFormatError): + decon_planner.entity_filters = "incorrect" + + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + decon_planner.entity_filters = "incorrect" + assert decon_planner.entity_filters == "incorrect" + assert decon_planner.to_dict()["deconstruction_planner"] == { + "item": "deconstruction-planner", + "version": encode_version(*__factorio_version_info__), + "settings": {"entity_filters": "incorrect"}, + } + def test_set_trees_and_rocks_only(self): decon_planner = DeconstructionPlanner() decon_planner.trees_and_rocks_only = True assert decon_planner.trees_and_rocks_only == True - assert decon_planner["deconstruction_planner"]["settings"]["trees_and_rocks_only"] == True + assert ( + decon_planner["deconstruction_planner"]["settings"]["trees_and_rocks_only"] + == True + ) decon_planner.trees_and_rocks_only = False assert decon_planner.trees_and_rocks_only == False - assert decon_planner["deconstruction_planner"]["settings"]["trees_and_rocks_only"] == False + assert ( + decon_planner["deconstruction_planner"]["settings"]["trees_and_rocks_only"] + == False + ) decon_planner.trees_and_rocks_only = None assert decon_planner.trees_and_rocks_only == None @@ -118,19 +198,31 @@ def test_set_trees_and_rocks_only(self): with pytest.raises(DataFormatError): decon_planner.trees_and_rocks_only = "incorrect" + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + decon_planner.trees_and_rocks_only = "incorrect" + assert decon_planner.trees_and_rocks_only == "incorrect" + assert decon_planner.to_dict()["deconstruction_planner"] == { + "item": "deconstruction-planner", + "version": encode_version(*__factorio_version_info__), + "settings": {"trees_and_rocks_only": "incorrect"}, + } + def test_set_tile_filter_mode(self): decon_planner = DeconstructionPlanner() decon_planner.tile_filter_mode = FilterMode.WHITELIST assert decon_planner.tile_filter_mode == FilterMode.WHITELIST assert ( - decon_planner["deconstruction_planner"]["settings"]["tile_filter_mode"] == FilterMode.WHITELIST + decon_planner["deconstruction_planner"]["settings"]["tile_filter_mode"] + == FilterMode.WHITELIST ) decon_planner.tile_filter_mode = FilterMode.BLACKLIST assert decon_planner.tile_filter_mode == FilterMode.BLACKLIST assert ( - decon_planner["deconstruction_planner"]["settings"]["tile_filter_mode"] == FilterMode.BLACKLIST + decon_planner["deconstruction_planner"]["settings"]["tile_filter_mode"] + == FilterMode.BLACKLIST ) decon_planner.tile_filter_mode = None @@ -141,6 +233,16 @@ def test_set_tile_filter_mode(self): with pytest.raises(DataFormatError): decon_planner.tile_filter_mode = "incorrect" + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + decon_planner.tile_filter_mode = "incorrect" + assert decon_planner.tile_filter_mode == "incorrect" + assert decon_planner.to_dict()["deconstruction_planner"] == { + "item": "deconstruction-planner", + "version": encode_version(*__factorio_version_info__), + "settings": {"tile_filter_mode": "incorrect"}, + } + def test_set_tile_filters(self): decon_planner = DeconstructionPlanner() @@ -161,20 +263,44 @@ def test_set_tile_filters(self): TileFilter(**{"name": "stone-path", "index": 2}), ] + # Test unknown entity + with pytest.warns(UnknownTileWarning): + decon_planner.tile_filters = ["unknown-thingy"] + assert decon_planner.tile_filters == [ + TileFilter(**{"name": "unknown-thingy", "index": 1}), + ] + + with pytest.raises(DataFormatError): + decon_planner.tile_filters = "incorrect" + + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + decon_planner.tile_filters = "incorrect" + assert decon_planner.tile_filters == "incorrect" + assert decon_planner.to_dict()["deconstruction_planner"] == { + "item": "deconstruction-planner", + "version": encode_version(*__factorio_version_info__), + "settings": {"tile_filters": "incorrect"}, + } + def test_tile_selection_mode(self): decon_planner = DeconstructionPlanner() decon_planner.tile_selection_mode = TileSelectionMode.NORMAL assert decon_planner.tile_selection_mode == TileSelectionMode.NORMAL assert ( - decon_planner._root["deconstruction_planner"]["settings"]["tile_selection_mode"] + decon_planner._root["deconstruction_planner"]["settings"][ + "tile_selection_mode" + ] == TileSelectionMode.NORMAL ) decon_planner.tile_selection_mode = TileSelectionMode.NEVER assert decon_planner.tile_selection_mode == TileSelectionMode.NEVER assert ( - decon_planner._root["deconstruction_planner"]["settings"]["tile_selection_mode"] + decon_planner._root["deconstruction_planner"]["settings"][ + "tile_selection_mode" + ] == TileSelectionMode.NEVER ) @@ -182,6 +308,16 @@ def test_tile_selection_mode(self): with pytest.raises(DataFormatError): decon_planner.tile_selection_mode = "incorrect" + decon_planner.validate_assignment = "none" + assert decon_planner.validate_assignment == ValidationMode.NONE + decon_planner.tile_selection_mode = "incorrect" + assert decon_planner.tile_selection_mode == "incorrect" + assert decon_planner.to_dict()["deconstruction_planner"] == { + "item": "deconstruction-planner", + "version": encode_version(*__factorio_version_info__), + "settings": {"tile_selection_mode": "incorrect"}, + } + def test_set_entity_filter(self): decon_planner = DeconstructionPlanner() @@ -189,28 +325,29 @@ def test_set_entity_filter(self): decon_planner.set_entity_filter(0, "transport-belt") decon_planner.set_entity_filter(1, "fast-transport-belt") assert decon_planner.entity_filters == [ - {"name": "transport-belt", "index": 1}, - {"name": "fast-transport-belt", "index": 2}, + EntityFilter(**{"name": "transport-belt", "index": 1}), + EntityFilter(**{"name": "fast-transport-belt", "index": 2}), ] # Duplicate case decon_planner.set_entity_filter(0, "transport-belt") assert decon_planner.entity_filters == [ - {"name": "transport-belt", "index": 1}, - {"name": "fast-transport-belt", "index": 2}, + EntityFilter(**{"name": "transport-belt", "index": 1}), + EntityFilter(**{"name": "fast-transport-belt", "index": 2}), ] # None case decon_planner.set_entity_filter(0, None) assert decon_planner.entity_filters == [ - {"name": "fast-transport-belt", "index": 2} + EntityFilter(**{"name": "fast-transport-belt", "index": 2}) ] - # Errors - # with pytest.raises(IndexError): - # decon_planner.set_entity_filter(100, "transport-belt") + # TODO + # with pytest.raises(DataFormatError): + # decon_planner.set_entity_filter("incorrect", None) - # TODO: check for invalid input names + with pytest.raises(DataFormatError): + decon_planner.set_entity_filter("incorrect", "incorrect") def test_set_tile_filter(self): decon_planner = DeconstructionPlanner() @@ -219,23 +356,26 @@ def test_set_tile_filter(self): decon_planner.set_tile_filter(0, "concrete") decon_planner.set_tile_filter(1, "stone-path") assert decon_planner.tile_filters == [ - {"name": "concrete", "index": 1}, - {"name": "stone-path", "index": 2}, + TileFilter(**{"name": "concrete", "index": 1}), + TileFilter(**{"name": "stone-path", "index": 2}), ] # Duplicate case decon_planner.set_tile_filter(0, "concrete") assert decon_planner.tile_filters == [ - {"name": "concrete", "index": 1}, - {"name": "stone-path", "index": 2}, + TileFilter(**{"name": "concrete", "index": 1}), + TileFilter(**{"name": "stone-path", "index": 2}), ] # None case decon_planner.set_tile_filter(0, None) - assert decon_planner.tile_filters == [{"name": "stone-path", "index": 2}] + assert decon_planner.tile_filters == [ + TileFilter(**{"name": "stone-path", "index": 2}) + ] - # Errors - # with pytest.raises(IndexError): - # decon_planner.set_tile_filter(100, "concrete") + # TODO + # with pytest.raises(DataFormatError): + # decon_planner.set_tile_filter("incorrect", None) - # TODO: check for invalid input names + with pytest.raises(DataFormatError): + decon_planner.set_tile_filter("incorrect", "incorrect") diff --git a/test/test_entity.py b/test/test_entity.py index ec03e37..a06f84b 100644 --- a/test/test_entity.py +++ b/test/test_entity.py @@ -178,6 +178,9 @@ def test_flippable(self): belt = TransportBelt() assert belt.flippable == True + def test_contains(self): + assert "name" in TransportBelt() + # ============================================================================= # Factory function new_entity() @@ -370,12 +373,54 @@ def test_burner_generator(self): def test_player_port(self): assert isinstance(new_entity("player-port"), PlayerPort) - def test_errors(self): - with pytest.raises(InvalidEntityError, match="'incorrect' is not a recognized entity"): - new_entity("incorrect") + def test_unknown(self): + # Invalid unknown value + with pytest.raises(ValueError): + new_entity("unknown", if_unknown="wrong") + + # Default behavior is error + # Raise errors (if desired) + with pytest.raises(InvalidEntityError, match="Unknown entity 'unknown'"): + new_entity("unknown") + + # Ignore + assert new_entity("unknown", if_unknown="ignore") == None + + # Try and treat as a generic entity + result = new_entity("unknown", position=(0.5, 0.5), if_unknown="accept") + assert isinstance(result, Entity) + + # Generic entities should be able to handle attribute access and serialization + assert result.name == "unknown" + assert result.position == Vector(0.5, 0.5) + assert result.to_dict() == { + "name": "unknown", + "position": {"x": 0.5, "y": 0.5} + } + + # You should also be able to set new attributes to them without Draftsman + # complaining + result = new_entity("unknown", position=(0.5, 0.5), direction=4, if_unknown="accept") + assert result.to_dict() == { + "name": "unknown", + "position": {"x": 0.5, "y": 0.5}, + "direction": 4 + } + + # After construction, as well + result["new_thing"] = "extra!" + assert result.to_dict() == { + "name": "unknown", + "position": {"x": 0.5, "y": 0.5}, + "direction": 4, + "new_thing": "extra!" + } + result.validate(mode=ValidationMode.PEDANTIC).reissue_all() + + # However, setting known attributes incorrectly should still create + # issues + with pytest.raises(DataFormatError): + result.tags = "incorrect" - def test_suggestion_error(self): - with pytest.raises(InvalidEntityError, match="'sgtorage-tank' is not a recognized entity; did you mean 'storage-tank'?"): - new_entity("sgtorage-tank") # fmt: on diff --git a/test/test_entity_list.py b/test/test_entity_list.py index 4513ba4..3937e8e 100644 --- a/test/test_entity_list.py +++ b/test/test_entity_list.py @@ -1,7 +1,4 @@ # test_entity_list.py -# -*- encoding: utf-8 -*- - -from __future__ import absolute_import, unicode_literals from draftsman._factorio_version import __factorio_version_info__ from draftsman.classes.blueprint import Blueprint @@ -12,16 +9,10 @@ from draftsman.utils import encode_version from draftsman.warning import OverlappingObjectsWarning, HiddenEntityWarning -import sys import pytest -if sys.version_info >= (3, 3): # pragma: no coverage - import unittest -else: # pragma: no coverage - import unittest2 as unittest - -class EntityListTesting(unittest.TestCase): +class TestEntityList: def test_constructor(self): blueprint = Blueprint() test = EntityList(blueprint) @@ -141,6 +132,34 @@ def test_recursive_remove(self): with pytest.raises(ValueError): blueprint.entities.recursive_remove(entity_to_remove) + def test_union(self): + blueprint1 = Blueprint() + + blueprint1.entities.append("wooden-chest") + + blueprint2 = Blueprint() + + blueprint2.entities.append("inserter", direction=2, tile_position=(1, 0)) + + blueprint3 = Blueprint() + + blueprint3.entities = blueprint1.entities | blueprint2.entities + + assert len(blueprint3.entities) == 2 + assert blueprint3.to_dict()["blueprint"]["entities"] == [ + { + "name": "wooden-chest", + "position": {"x": 0.5, "y": 0.5}, + "entity_number": 1, + }, + { + "name": "inserter", + "position": {"x": 1.5, "y": 0.5}, + "direction": 2, + "entity_number": 2, + }, + ] + def test_getitem(self): blueprint = Blueprint() test = EntityList(blueprint) @@ -260,3 +279,22 @@ def test_contains(self): assert entityB in blueprint.entities assert entityC not in group.entities assert entityC in blueprint.entities + + def test_eq(self): + blueprint1 = Blueprint() + blueprint1.entities.append("wooden-chest") + + assert blueprint1.entities != 10 + + blueprint2 = Blueprint() + + assert blueprint1.entities != blueprint2.entities + + blueprint2.entities.append("inserter") + + assert blueprint1.entities != blueprint2.entities + + blueprint2.entities.pop(0) + blueprint2.entities.append("wooden-chest") + + assert blueprint1.entities == blueprint2.entities diff --git a/test/test_group.py b/test/test_group.py index d3402a6..90bedf2 100644 --- a/test/test_group.py +++ b/test/test_group.py @@ -118,16 +118,20 @@ def test_constructor_init(self): assert len(group.schedules) == 1 assert group.schedules[0].locomotives[0]() is group.entities[0] assert group.schedules[0].stops == [ - { - "station": "AEnterprise", - "wait_conditions": WaitConditions( - [ - WaitCondition(type="time", compare_type="or", ticks=1800), - WaitCondition(type="inactivity", compare_type="and"), - WaitCondition(type="full", compare_type="and"), - ] - ), - } + Schedule.Format.Stop( + **{ + "station": "AEnterprise", + "wait_conditions": WaitConditions( + [ + WaitCondition(type="time", compare_type="or", ticks=1800), + WaitCondition( + type="inactivity", compare_type="and", ticks=300 + ), + WaitCondition(type="full", compare_type="and"), + ] + ), + } + ) ] # Initialize from blueprint string with no entities @@ -376,19 +380,19 @@ def test_set_schedules(self): # ScheduleList schedule = Schedule() schedule.add_locomotive(group.entities["test_train"]) - schedule.append_stop( - "station_name", WaitCondition("inactivity", ticks=600) - ) + schedule.append_stop("station_name", WaitCondition("inactivity", ticks=600)) group.schedules = ScheduleList([schedule]) assert isinstance(group.schedules, ScheduleList) assert group.schedules[0].locomotives[0]() is group.entities[0] assert group.schedules[0].stops == [ - { - "station": "station_name", - "wait_conditions": WaitConditions( - [WaitCondition("inactivity", ticks=600)] - ), - } + Schedule.Format.Stop( + **{ + "station": "station_name", + "wait_conditions": WaitConditions( + [WaitCondition("inactivity", ticks=600)] + ), + } + ) ] # None diff --git a/test/test_schedule.py b/test/test_schedule.py index 8fbb5b0..c2ab85d 100644 --- a/test/test_schedule.py +++ b/test/test_schedule.py @@ -3,6 +3,8 @@ from draftsman.classes.blueprint import Blueprint from draftsman.classes.schedule import Schedule, WaitCondition, WaitConditions from draftsman.constants import WaitConditionType, WaitConditionCompareType +from draftsman.error import DataFormatError +from draftsman.signatures import Condition import pytest import re @@ -18,14 +20,28 @@ def test_constructor(self): assert w.condition == None # Inactivity - w = WaitCondition( - "inactivity", compare_type="and" - ) + w = WaitCondition("inactivity", compare_type="and") assert w.type == "inactivity" assert w.compare_type == "and" assert w.ticks == 300 assert w.condition == None + w = WaitCondition("circuit") + assert w.type == "circuit" + assert w.compare_type == "or" + assert w.ticks == None + assert w.condition == Condition() + + w = WaitCondition("incorrect", compare_type="incorrect", validate="none") + assert w.type == "incorrect" + assert w.compare_type == "incorrect" + assert w.ticks == None + assert w.condition == None + assert w.to_dict() == {"type": "incorrect", "compare_type": "incorrect"} + + with pytest.raises(DataFormatError): + w.validate().reissue_all() + def test_to_dict(self): w = WaitCondition( "circuit", @@ -41,9 +57,7 @@ def test_to_dict(self): }, } - w = WaitCondition( - "circuit", condition=("signal-A", "<", 100) - ) + w = WaitCondition("circuit", condition=("signal-A", "<", 100)) assert w.to_dict() == { "type": "circuit", # "compare_type": "or", # Default @@ -54,6 +68,61 @@ def test_to_dict(self): }, } + def test_set_type(self): + full_cargo = WaitCondition("full") + + full_cargo.type = WaitConditionType.EMPTY_CARGO + assert full_cargo.type == WaitConditionType.EMPTY_CARGO + + assert full_cargo == WaitCondition("empty") + + with pytest.raises(DataFormatError): + full_cargo.type = "string, but not one of valid literals" + + def test_set_ticks(self): + time_passed = WaitCondition("time") + time_passed.ticks = 1000 + assert time_passed.ticks == 1000 + + # You can set ticks on any WaitCondition object + # TODO: maybe issue a warning if this is done? + full_cargo = WaitCondition("full") + full_cargo.ticks = 100 + assert full_cargo.ticks == 100 + + with pytest.raises(DataFormatError): + full_cargo.ticks = "incorrect" + + def test_set_condition(self): + circuit_condition = WaitCondition("circuit") + circuit_condition.condition = { + "first_signal": "signal-A", + "comparator": ">", + "constant": 1000, + } + assert circuit_condition.condition == Condition( + **{"first_signal": "signal-A", "comparator": ">", "constant": 1000} + ) + + # You can set condition on any WaitCondition object + # TODO: maybe issue a warning if this is done? + full_cargo = WaitCondition("full") + full_cargo.condition = { + "first_signal": "signal-A", + "comparator": "==", + "second_signal": "signal-B", + } + assert full_cargo.condition == Condition( + **{ + "first_signal": "signal-A", + "comparator": "==", + "second_signal": "signal-B", + } + ) + + with pytest.raises(DataFormatError): + full_cargo.condition = "incorrect" + def test_bitwise_and(self): # WaitCondition and WaitCondition full_cargo = WaitCondition("full") @@ -70,9 +139,7 @@ def test_bitwise_and(self): ) # WaitCondition and WaitConditions - signal_sent = WaitCondition( - "circuit", condition=("signal-A", "==", 100) - ) + signal_sent = WaitCondition("circuit", condition=("signal-A", "==", 100)) sum1 = signal_sent & conditions assert isinstance(sum1, WaitConditions) assert len(sum1) == 3 @@ -89,8 +156,10 @@ def test_bitwise_and(self): # WaitCondition and error type with pytest.raises( - ValueError, - match="Can only perform this operation on or objects", + TypeError, + match=re.escape( + "unsupported operand type(s) for &: 'WaitCondition' and 'Schedule'" + ), ): signal_sent & Schedule() @@ -112,8 +181,10 @@ def test_bitwise_and(self): # Error type and WaitCondition with pytest.raises( - ValueError, - match="Can only perform this operation on or objects", + TypeError, + match=re.escape( + "unsupported operand type(s) for &: 'Schedule' and 'WaitCondition'" + ), ): Schedule() & signal_sent @@ -133,9 +204,7 @@ def test_bitwise_or(self): ) # WaitCondition and WaitConditions - signal_sent = WaitCondition( - "circuit", condition = ("signal-A", "==", 100) - ) + signal_sent = WaitCondition("circuit", condition=("signal-A", "==", 100)) sum1 = signal_sent | conditions assert isinstance(sum1, WaitConditions) assert len(sum1) == 3 @@ -150,10 +219,10 @@ def test_bitwise_or(self): ] ) - # WaitCondition and error type + # Unsupported operation with pytest.raises( - ValueError, - match="Can only perform this operation on or objects", + TypeError, + match="unsupported operand type(s) for |: 'WaitCondition' and 'Schedule'", ): signal_sent | Schedule() @@ -174,25 +243,26 @@ def test_bitwise_or(self): # Error type and WaitCondition with pytest.raises( - ValueError, - match="Can only perform this operation on or objects", + TypeError, + match="unsupported operand type(s) for |: 'Schedule' and 'WaitCondition'", ): Schedule() | signal_sent def test_repr(self): w = WaitCondition("passenger_present") - assert repr(w) == "{type= compare_type='or' ticks=None condition=None}" - w = WaitCondition("inactivity") assert ( repr(w) - == "{type= compare_type='or' ticks=300 condition=None}" + == "{type= compare_type= ticks=None condition=None}" ) - w = WaitCondition( - "item_count", condition=("signal-A", "=", "signal-B") + w = WaitCondition("inactivity") + assert ( + repr(w) + == "{type= compare_type= ticks=300 condition=None}" ) + w = WaitCondition("item_count", condition=("signal-A", "=", "signal-B")) assert ( repr(w) - == "{type= compare_type='or' ticks=None condition=Condition(first_signal=SignalID(name='signal-A', type='virtual'), comparator='=', constant=0, second_signal=SignalID(name='signal-B', type='virtual'))}" + == "{type= compare_type= ticks=None condition=Condition(first_signal=SignalID(name='signal-A', type='virtual'), comparator='=', constant=0, second_signal=SignalID(name='signal-B', type='virtual'))}" ) @@ -207,10 +277,9 @@ def test_len(self): pass def test_getitem(self): - a = WaitConditions([ - WaitCondition("full"), - WaitCondition("inactivity", "and", ticks=1000) - ]) + a = WaitConditions( + [WaitCondition("full"), WaitCondition("inactivity", "and", ticks=1000)] + ) assert a[0].type == "full" assert a[1].type == "inactivity" @@ -251,9 +320,19 @@ def test_constructor(self): ) assert s.locomotives == [] assert s.stops == [ - {"station": "some name", "wait_conditions": WaitConditions([])} + Schedule.Format.Stop( + **{"station": "some name", "wait_conditions": WaitConditions([])} + ) ] + with pytest.raises(DataFormatError): + s = Schedule(locomotives="incorrect") + + s = Schedule(locomotives="incorrect", validate="none") + assert s.to_dict() == { + "locomotives": "incorrect", + } + def test_locomotives(self): pass # TODO @@ -305,7 +384,9 @@ def test_insert_stop(self): s.insert_stop(0, "Station A") assert len(s.stops) == 1 assert s.stops == [ - {"station": "Station A", "wait_conditions": WaitConditions()} + Schedule.Format.Stop( + **{"station": "Station A", "wait_conditions": WaitConditions()} + ) ] full_cargo = WaitCondition("full") @@ -315,35 +396,41 @@ def test_insert_stop(self): s.insert_stop(1, "Station B", full_cargo) assert len(s.stops) == 2 assert s.stops == [ - {"station": "Station A", "wait_conditions": WaitConditions()}, - { - "station": "Station B", - "wait_conditions": WaitConditions( - [WaitCondition("full")] - ), - }, + Schedule.Format.Stop( + **{"station": "Station A", "wait_conditions": WaitConditions()} + ), + Schedule.Format.Stop( + **{ + "station": "Station B", + "wait_conditions": WaitConditions([WaitCondition("full")]), + } + ), ] # WaitConditions object s.insert_stop(2, "Station C", full_cargo & inactivity) assert len(s.stops) == 3 assert s.stops == [ - {"station": "Station A", "wait_conditions": WaitConditions()}, - { - "station": "Station B", - "wait_conditions": WaitConditions( - [WaitCondition("full")] - ), - }, - { - "station": "Station C", - "wait_conditions": WaitConditions( - [ - WaitCondition("full"), - WaitCondition("inactivity", compare_type="and"), - ] - ), - }, + Schedule.Format.Stop( + **{"station": "Station A", "wait_conditions": WaitConditions()} + ), + Schedule.Format.Stop( + **{ + "station": "Station B", + "wait_conditions": WaitConditions([WaitCondition("full")]), + } + ), + Schedule.Format.Stop( + **{ + "station": "Station C", + "wait_conditions": WaitConditions( + [ + WaitCondition("full"), + WaitCondition("inactivity", compare_type="and"), + ] + ), + } + ), ] def test_remove_stop(self): @@ -357,21 +444,48 @@ def test_remove_stop(self): s.append_stop("Station A", wait_conditions=inactivity) assert len(s.stops) == 3 assert s.stops == [ - {"station": "Station A", "wait_conditions": WaitConditions()}, - {"station": "Station A", "wait_conditions": WaitConditions([full_cargo])}, - {"station": "Station A", "wait_conditions": WaitConditions([inactivity])}, + Schedule.Format.Stop( + **{"station": "Station A", "wait_conditions": WaitConditions()} + ), + Schedule.Format.Stop( + **{ + "station": "Station A", + "wait_conditions": WaitConditions([full_cargo]), + } + ), + Schedule.Format.Stop( + **{ + "station": "Station A", + "wait_conditions": WaitConditions([inactivity]), + } + ), ] # Remove with no wait_conditions s.remove_stop("Station A") assert len(s.stops) == 2 assert s.stops == [ - {"station": "Station A", "wait_conditions": WaitConditions([full_cargo])}, - {"station": "Station A", "wait_conditions": WaitConditions([inactivity])}, + Schedule.Format.Stop( + **{ + "station": "Station A", + "wait_conditions": WaitConditions([full_cargo]), + } + ), + Schedule.Format.Stop( + **{ + "station": "Station A", + "wait_conditions": WaitConditions([inactivity]), + } + ), ] s.remove_stop("Station A") assert s.stops == [ - {"station": "Station A", "wait_conditions": WaitConditions([inactivity])} + Schedule.Format.Stop( + **{ + "station": "Station A", + "wait_conditions": WaitConditions([inactivity]), + } + ) ] # Remove stop with wait_conditions that doesn't exist @@ -385,6 +499,7 @@ def test_remove_stop(self): s.remove_stop("Station B") # Remove with wait conditions + print(s.stops) s.remove_stop("Station A", wait_conditions=inactivity) assert len(s.stops) == 0 assert s.stops == [] @@ -395,9 +510,6 @@ def test_remove_stop(self): ): s.remove_stop("Station A") - def test_to_dict(self): - pass # TODO - def test_repr(self): s = Schedule() - assert repr(s) == "{'locomotives': [], 'schedule': []}" + assert repr(s) == "{}" diff --git a/test/test_schedule_list.py b/test/test_schedule_list.py index 36dd28e..2e239a0 100644 --- a/test/test_schedule_list.py +++ b/test/test_schedule_list.py @@ -32,6 +32,9 @@ def test_setitem(self): ): sl[0] = TypeError + with pytest.raises(TypeError): + sl[0:1] = [TypeError, TypeError] + def test_delitem(self): sl = ScheduleList() sl.append(Schedule()) diff --git a/test/test_tile.py b/test/test_tile.py index c4b6d22..e881472 100644 --- a/test/test_tile.py +++ b/test/test_tile.py @@ -1,19 +1,13 @@ # tile.py -# -*- encoding: utf-8 -*- from draftsman.tile import Tile -from draftsman.error import InvalidTileError +from draftsman.error import DataFormatError +from draftsman.warning import UnknownTileWarning -import sys import pytest -if sys.version_info >= (3, 3): # pragma: no coverage - import unittest -else: # pragma: no coverage - import unittest2 as unittest - -class TileTesting(unittest.TestCase): +class TestTile: def test_constructor(self): # Specific position tile = Tile("hazard-concrete-right", (100, -100)) @@ -27,23 +21,22 @@ def test_constructor(self): assert tile.position.y == 0 # Invalid name - with pytest.raises( - InvalidTileError, match="'weeeeee' is not a valid name for this Tile" - ): + with pytest.warns(UnknownTileWarning, match="Unknown tile 'weeeeee'"): tile = Tile("weeeeee") - issues = tile.inspect() - for error in issues: - raise error # Invalid name with suggestion - with pytest.raises( - InvalidTileError, - match="'stonepath' is not a valid name for this Tile; did you mean 'stone-path'?", + with pytest.warns( + UnknownTileWarning, + match="Unknown tile 'stonepath'; did you mean 'stone-path'?", ): tile = Tile("stonepath") - issues = tile.inspect() - for error in issues: - raise error + + with pytest.raises(DataFormatError): + tile = Tile(name=100) + + # validation off + tile = Tile(name=100, validate="none") + assert tile.name == 100 # TODO: test closure # with self.assertRaises(InvalidTileError): @@ -63,11 +56,8 @@ def test_set_name(self): assert tile.position.y == 0 # Invalid name - with pytest.raises(InvalidTileError): + with pytest.warns(UnknownTileWarning): tile.name = "weeeeee" - issues = tile.inspect() - for error in issues: - raise error # TODO: test closure # with self.assertRaises(InvalidTileError): @@ -82,6 +72,10 @@ def test_set_position(self): assert tile.position.x == -123 assert tile.position.y == 123 + with pytest.raises(DataFormatError): + tile._root.position = "incorrect" + tile.validate().reissue_all() + def test_to_dict(self): tile = Tile("landfill", position=(123, 123)) assert tile.to_dict() == {"name": "landfill", "position": {"x": 123, "y": 123}} diff --git a/test/test_upgrade_planner.py b/test/test_upgrade_planner.py index 04efe9d..cae5b3e 100644 --- a/test/test_upgrade_planner.py +++ b/test/test_upgrade_planner.py @@ -3,6 +3,7 @@ from draftsman import __factorio_version_info__ from draftsman.classes.upgrade_planner import UpgradePlanner from draftsman.classes.exportable import ValidationResult +from draftsman.constants import ValidationMode from draftsman.data import entities from draftsman.error import ( IncorrectBlueprintTypeError, @@ -10,7 +11,7 @@ DataFormatError, InvalidMapperError, ) -from draftsman.signatures import Icons, Mapper +from draftsman.signatures import Icon, Mapper from draftsman.utils import encode_version from draftsman.warning import ( DraftsmanWarning, @@ -93,7 +94,10 @@ def test_description(self): # Normal case upgrade_planner.description = "some description" assert upgrade_planner.description == "some description" - assert upgrade_planner["upgrade_planner"]["settings"]["description"] is upgrade_planner.description + assert ( + upgrade_planner["upgrade_planner"]["settings"]["description"] + is upgrade_planner.description + ) assert upgrade_planner.to_dict()["upgrade_planner"] == { "item": "upgrade-planner", "version": encode_version(*__factorio_version_info__), @@ -109,6 +113,12 @@ def test_description(self): "version": encode_version(*__factorio_version_info__), } + upgrade_planner.validate_assignment = "none" + assert upgrade_planner.validate_assignment == ValidationMode.NONE + + upgrade_planner.description = 100 + assert upgrade_planner.description == 100 + def test_icons(self): upgrade_planner = UpgradePlanner() @@ -116,10 +126,13 @@ def test_icons(self): upgrade_planner.icons = [ {"index": 1, "signal": {"name": "signal-A", "type": "virtual"}} ] - assert upgrade_planner.icons == Icons(root=[ - {"index": 1, "signal": {"name": "signal-A", "type": "virtual"}} - ]) - assert upgrade_planner["upgrade_planner"]["settings"]["icons"] is upgrade_planner.icons + assert upgrade_planner.icons == [ + Icon(**{"index": 1, "signal": {"name": "signal-A", "type": "virtual"}}) + ] + assert ( + upgrade_planner["upgrade_planner"]["settings"]["icons"] + is upgrade_planner.icons + ) assert upgrade_planner.to_dict()["upgrade_planner"] == { "item": "upgrade-planner", "version": encode_version(*__factorio_version_info__), @@ -139,16 +152,25 @@ def test_icons(self): "version": encode_version(*__factorio_version_info__), } + upgrade_planner.validate_assignment = "none" + assert upgrade_planner.validate_assignment == ValidationMode.NONE + + upgrade_planner.icons = "incorrect" + assert upgrade_planner.icons == "incorrect" + def test_set_icons(self): upgrade_planner = UpgradePlanner() # Single known # upgrade_planner.set_icons("signal-A") upgrade_planner.icons = ["signal-A"] - assert upgrade_planner.icons == Icons(root=[ - {"index": 1, "signal": {"name": "signal-A", "type": "virtual"}} - ]) - assert upgrade_planner["upgrade_planner"]["settings"]["icons"] is upgrade_planner.icons + assert upgrade_planner.icons == [ + Icon(**{"index": 1, "signal": {"name": "signal-A", "type": "virtual"}}) + ] + assert ( + upgrade_planner["upgrade_planner"]["settings"]["icons"] + is upgrade_planner.icons + ) assert upgrade_planner.to_dict()["upgrade_planner"] == { "item": "upgrade-planner", "version": encode_version(*__factorio_version_info__), @@ -161,11 +183,11 @@ def test_set_icons(self): # Multiple known upgrade_planner.icons = ["signal-A", "signal-B", "signal-C"] - assert upgrade_planner.icons == Icons(root=[ - {"index": 1, "signal": {"name": "signal-A", "type": "virtual"}}, - {"index": 2, "signal": {"name": "signal-B", "type": "virtual"}}, - {"index": 3, "signal": {"name": "signal-C", "type": "virtual"}}, - ]) + assert upgrade_planner.icons == [ + Icon(**{"index": 1, "signal": {"name": "signal-A", "type": "virtual"}}), + Icon(**{"index": 2, "signal": {"name": "signal-B", "type": "virtual"}}), + Icon(**{"index": 3, "signal": {"name": "signal-C", "type": "virtual"}}), + ] assert upgrade_planner.to_dict()["upgrade_planner"] == { "item": "upgrade-planner", "version": encode_version(*__factorio_version_info__), @@ -201,22 +223,32 @@ def test_mappers(self): }, ] assert upgrade_planner.mappers == [ - Mapper(**{ - "from": {"name": "transport-belt", "type": "entity"}, - "to": {"name": "fast-transport-belt", "type": "entity"}, - "index": 0, - }), - Mapper(**{ - "from": {"name": "transport-belt", "type": "entity"}, - "to": {"name": "express-transport-belt", "type": "entity"}, - "index": 23, - }), + Mapper( + **{ + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "fast-transport-belt", "type": "entity"}, + "index": 0, + } + ), + Mapper( + **{ + "from": {"name": "transport-belt", "type": "entity"}, + "to": {"name": "express-transport-belt", "type": "entity"}, + "index": 23, + } + ), ] # Test None upgrade_planner.mappers = None assert upgrade_planner.mappers == None + upgrade_planner.validate_assignment = "none" + assert upgrade_planner.validate_assignment == ValidationMode.NONE + + upgrade_planner.mappers = "incorrect" + assert upgrade_planner.mappers == "incorrect" + def test_set_mapping(self): upgrade_planner = UpgradePlanner() upgrade_planner.set_mapping("transport-belt", "fast-transport-belt", 0) @@ -329,57 +361,71 @@ def test_pop_mapping(self): upgrade_planner = UpgradePlanner(validate_assignment="minimum") upgrade_planner.mappers = [ - Mapper(**{ - "to": {"name": "transport-belt", "type": "entity"}, - "from": {"name": "express-transport-belt", "type": "entity"}, - "index": 1, - }), - Mapper(**{ - "to": {"name": "assembling-machine-1", "type": "entity"}, - "from": {"name": "assembling-machine-2", "type": "entity"}, - "index": 1, - }), - Mapper(**{ - "to": {"name": "transport-belt", "type": "entity"}, - "from": {"name": "fast-transport-belt", "type": "entity"}, - "index": 0, - }), + Mapper( + **{ + "to": {"name": "transport-belt", "type": "entity"}, + "from": {"name": "express-transport-belt", "type": "entity"}, + "index": 1, + } + ), + Mapper( + **{ + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine-2", "type": "entity"}, + "index": 1, + } + ), + Mapper( + **{ + "to": {"name": "transport-belt", "type": "entity"}, + "from": {"name": "fast-transport-belt", "type": "entity"}, + "index": 0, + } + ), ] # Remove mapping with index 0 upgrade_planner.pop_mapping(0) assert upgrade_planner.mappers == [ - Mapper(**{ - "to": {"name": "transport-belt", "type": "entity"}, - "from": {"name": "express-transport-belt", "type": "entity"}, - "index": 1, - }), - Mapper(**{ - "to": {"name": "assembling-machine-1", "type": "entity"}, - "from": {"name": "assembling-machine-2", "type": "entity"}, - "index": 1, - }), + Mapper( + **{ + "to": {"name": "transport-belt", "type": "entity"}, + "from": {"name": "express-transport-belt", "type": "entity"}, + "index": 1, + } + ), + Mapper( + **{ + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine-2", "type": "entity"}, + "index": 1, + } + ), ] # Remove first mapping with specified index upgrade_planner.pop_mapping(1) assert upgrade_planner.mappers == [ - Mapper(**{ - "to": {"name": "assembling-machine-1", "type": "entity"}, - "from": {"name": "assembling-machine-2", "type": "entity"}, - "index": 1, - }), + Mapper( + **{ + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine-2", "type": "entity"}, + "index": 1, + } + ), ] # Remove mapping with index not in mappers with pytest.raises(ValueError): upgrade_planner.pop_mapping(10) assert upgrade_planner.mappers == [ - Mapper(**{ - "to": {"name": "assembling-machine-1", "type": "entity"}, - "from": {"name": "assembling-machine-2", "type": "entity"}, - "index": 1, - }), + Mapper( + **{ + "to": {"name": "assembling-machine-1", "type": "entity"}, + "from": {"name": "assembling-machine-2", "type": "entity"}, + "index": 1, + } + ), ] def test_validate(self): @@ -404,6 +450,39 @@ def test_validate(self): with pytest.raises(DataFormatError): upgrade_planner.mappers = [TypeError, TypeError] + upgrade_planner.mappers = [ + ("assembling-machine-3", None), + (None, "assembling-machine-3"), + ] + assert upgrade_planner.mappers == [ + Mapper( + **{ + "index": 0, + "from": {"name": "assembling-machine-3", "type": "entity"}, + "to": None, + } + ), + Mapper( + **{ + "index": 1, + "from": None, + "to": {"name": "assembling-machine-3", "type": "entity"}, + } + ), + ] + + # Test items + upgrade_planner.mappers = [("speed-module", "speed-module-3")] + assert upgrade_planner.mappers == [ + Mapper( + **{ + "index": 0, + "from": {"name": "speed-module", "type": "item"}, + "to": {"name": "speed-module-3", "type": "item"}, + } + ) + ] + # Test validation failure upgrade_planner = UpgradePlanner() upgrade_planner.set_mapping("transport-belt", "transport-belt", -1) @@ -454,7 +533,10 @@ def test_validate(self): with pytest.warns(UnknownElementWarning): validation_result.reissue_all() - # dummy entity for testing purposes + # Known mappers, but mismatch between their types + upgrade_planner = UpgradePlanner() + with pytest.warns(UpgradeProhibitedWarning): + upgrade_planner.mappers = [("speed-module-3", "electric-furnace")] # "not-upgradable" flag in from upgrade_planner = UpgradePlanner() @@ -463,7 +545,9 @@ def test_validate(self): upgrade_planner.set_mapping("dummy-entity-1", "fast-transport-belt", 0) goal = ValidationResult( error_list=[], - warning_list=[UpgradeProhibitedWarning("'dummy-entity-1' is not upgradable")], + warning_list=[ + UpgradeProhibitedWarning("'dummy-entity-1' is not upgradable") + ], ) validation_result = upgrade_planner.validate() assert validation_result == goal diff --git a/test/test_utils.py b/test/test_utils.py index cdb49a0..4eccddc 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -3,6 +3,7 @@ from collections import OrderedDict from draftsman import utils from draftsman.classes.vector import Vector +from draftsman.constants import Direction, Orientation, Ticks, ValidationMode from draftsman.error import InvalidSignalError from draftsman.data import recipes, signals @@ -98,6 +99,17 @@ def test_rotate(self): with pytest.raises(ValueError): aabb.rotate(1) + def test_add(self): + aabb = utils.AABB(0, 0, 1, 1) + new_aabb = aabb + (10, 10) + assert new_aabb.position == Vector(10, 10) + assert new_aabb.get_bounding_box() == utils.AABB( + 10, + 10, + 11, + 11, + ) + def test_eq(self): assert utils.AABB(0, 0, 1, 1) == utils.AABB(0, 0, 1, 1) assert utils.AABB(0, 0, 1, 1) != utils.AABB(1, 1, 2, 2) @@ -128,6 +140,52 @@ def test_eq(self): pass +class TestConstants: + def test_orientation_to_direction(self): + assert Orientation.NORTH.to_direction() == Direction.NORTH + assert Orientation.NORTHEAST.to_direction() == Direction.NORTH + assert Orientation.NORTHEAST.to_direction(eight_way=True) == Direction.NORTHEAST + + def test_orientation_add(self): + assert Orientation(0.75) + Orientation(0.5) == Orientation(0.25) + assert Orientation(0.75) + 0.5 == Orientation(0.25) + assert 0.75 + Orientation(0.5) == Orientation(0.25) + with pytest.raises(TypeError): + Orientation.NORTH + "string" + with pytest.raises(TypeError): + "string" + Orientation.NORTH + + def test_orientation_sub(self): + assert Orientation(0.25) - Orientation(0.5) == Orientation(0.75) + assert Orientation(0.25) - 0.5 == Orientation(0.75) + assert 0.25 - Orientation(0.5) == Orientation(0.75) + with pytest.raises(TypeError): + Orientation.NORTH - "string" + with pytest.raises(TypeError): + "string" - Orientation.NORTH + + def test_eq(self): + assert not Orientation.NORTH == "wrong" + + def test_lt(self): + assert Orientation.NORTH < Orientation.SOUTH + with pytest.raises(TypeError): + Orientation.NORTH < "wrong" + + def test_ticks_from_timedelta(self): + from datetime import timedelta + + td = timedelta(1, 123, 2 * 8) + assert Ticks.from_timedelta(td) == 5191380 + + def test_validation_mode_eq(self): + assert ValidationMode.MINIMUM != TypeError + + def test_validation_mode_gt(self): + with pytest.raises(TypeError): + ValidationMode.NONE > TypeError + + class TestUtils: def test_string_to_JSON(self): # Blueprints @@ -307,21 +365,19 @@ def test_extend_aabb(self): def test_aabb_to_dimensions(self): assert utils.aabb_to_dimensions(utils.AABB(-5, -5, 10, 0)) == (15, 5) - def test_get_recipe_ingredients(self): - # Normal, list-type - assert recipes.get_recipe_ingredients("wooden-chest") == {"wood"} - # Normal, dict-type - assert recipes.get_recipe_ingredients("plastic-bar") == { - "petroleum-gas", - "coal", - } - # Expensive, list-type - assert recipes.get_recipe_ingredients("iron-gear-wheel") == {"iron-plate"} - # Custom examples - recipes.raw["test-1"] = {"ingredients": [["iron-plate", 2]]} - assert recipes.get_recipe_ingredients("test-1") == {"iron-plate"} - recipes.raw["test-2"] = {"normal": {"ingredients": [{"name": "iron-plate"}]}} - assert recipes.get_recipe_ingredients("test-2") == {"iron-plate"} + def test_parse_energy(self): + # Normal + assert utils.parse_energy("100J") == 100 + assert utils.parse_energy("1000KJ") == 1_000_000 + assert utils.parse_energy("60MW") == 1_000_000 + + # Unknown unit type specifier + with pytest.raises(ValueError): + utils.parse_energy("100MY") + + # Unknown unit scale specifier + with pytest.raises(ValueError): + utils.parse_energy("100QW") def test_reissue_warnings(self): @utils.reissue_warnings diff --git a/test/test_vector.py b/test/test_vector.py index 65f3d7b..c487d50 100644 --- a/test/test_vector.py +++ b/test/test_vector.py @@ -1,17 +1,9 @@ # test_vector.py -# -*- encoding: utf-8 -*- from draftsman.classes.vector import Vector -import sys -if sys.version_info >= (3, 3): # pragma: no coverage - import unittest -else: # pragma: no coverage - import unittest2 as unittest - - -class VectorTesting(unittest.TestCase): +class TestVector: def test_constructor(self): point = Vector(10, 2.3) assert point.x == 10