Compare commits

..

8 Commits

31 changed files with 811 additions and 72 deletions

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
.venv
.git
dist
build
__pycache__
*.pyc
.pytest_cache
*.zip
*.spec
imgs
dist_assets

11
Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM python:3.12-slim
WORKDIR /app
COPY requirements-server.txt .
RUN pip install --no-cache-dir -r requirements-server.txt
COPY . .
EXPOSE 9123
CMD ["python", "pyfa_server.py"]

View File

@@ -23,8 +23,38 @@ rm -rf build dist
echo "Building binary with PyInstaller..."
uv run pyinstaller pyfa.spec
# cp oleacc* dist/pyfa/
# Sim server exe (console) into main dist folder
if [ -f dist/pyfa_server/pyfa-server.exe ]; then
cp dist/pyfa_server/pyfa-server.exe dist/pyfa/
fi
# Docker image (Python server)
DOCKER_REPO="${DOCKER_REPO:-docker.site.quack-lab.dev}"
IMAGE_NAME="${IMAGE_NAME:-pyfa-server}"
COMMIT_SHA=$(git rev-parse --short HEAD)
IMAGE_BASE="${DOCKER_REPO}/${IMAGE_NAME}"
echo ""
echo "Build complete! Binary is located at: dist/pyfa/pyfa.exe"
echo "You can run it with: dist/pyfa/pyfa.exe"
echo "Building Docker image..."
docker build -t "${IMAGE_BASE}:${COMMIT_SHA}" .
docker tag "${IMAGE_BASE}:${COMMIT_SHA}" "${IMAGE_BASE}:latest"
TAGS=$(git tag --points-at HEAD 2>/dev/null || true)
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && docker tag "${IMAGE_BASE}:${COMMIT_SHA}" "${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi
echo ""
echo "Build complete! dist/pyfa/pyfa.exe (GUI), dist/pyfa/pyfa-server.exe (POST /simulate :9123)"
echo ""
echo "Docker image built as:"
echo " - ${IMAGE_BASE}:${COMMIT_SHA}"
echo " - ${IMAGE_BASE}:latest"
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && echo " - ${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi

View File

@@ -1,7 +1,6 @@
import os
import sys
import yaml
import wx
from logbook import CRITICAL, DEBUG, ERROR, FingersCrossedHandler, INFO, Logger, NestedSetup, NullHandler, \
StreamHandler, TimedRotatingFileHandler, WARNING
@@ -67,20 +66,34 @@ LOGLEVEL_MAP = {
CATALOG = 'lang'
slotColourMapDark = {
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
errColorDark = wx.Colour(70, 20, 20)
slotColourMap = {
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
errColor = wx.Colour(204, 51, 51)
def get_slotColourMapDark():
import wx
return {
FittingSlot.LOW: wx.Colour(44, 36, 19), # yellow = low slots 24/13
FittingSlot.MED: wx.Colour(28, 39, 51), # blue = mid slots 8.1/9.5
FittingSlot.HIGH: wx.Colour(53, 31, 34), # red = high slots 6.5/11.5
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
def get_errColorDark():
import wx
return wx.Colour(70, 20, 20)
def get_slotColourMap():
import wx
return {
FittingSlot.LOW: wx.Colour(250, 235, 204), # yellow = low slots
FittingSlot.MED: wx.Colour(188, 215, 241), # blue = mid slots
FittingSlot.HIGH: wx.Colour(235, 204, 209), # red = high slots
FittingSlot.RIG: '',
FittingSlot.SUBSYSTEM: ''}
def get_errColor():
import wx
return wx.Colour(204, 51, 51)
def getClientSecret():

5
docker-compose.yml Normal file
View File

@@ -0,0 +1,5 @@
services:
pyfa-server:
image: docker.site.quack-lab.dev/pyfa-server:latest
ports:
- "9123:9123"

View File

@@ -5,9 +5,6 @@ import wx
import gui.mainFrame
from gui.contextMenu import ContextMenuSingle
from gui.fitCommands import (
GuiConvertMutatedLocalModuleCommand, GuiRevertMutatedLocalModuleCommand,
GuiConvertMutatedLocalDroneCommand, GuiRevertMutatedLocalDroneCommand)
from service.fit import Fit
_t = wx.GetTranslation
@@ -65,6 +62,8 @@ class ChangeItemMutation(ContextMenuSingle):
return sub
def handleMenu(self, event):
from gui.fitCommands import (
GuiConvertMutatedLocalModuleCommand, GuiConvertMutatedLocalDroneCommand)
mutaplasmid, item = self.eventIDs[event.Id]
fitID = self.mainFrame.getActiveFit()
fit = Fit.getInstance().getFit(fitID)
@@ -78,6 +77,8 @@ class ChangeItemMutation(ContextMenuSingle):
fitID=fitID, position=position, mutaplasmid=mutaplasmid))
def activate(self, callingWindow, fullContext, mainItem, i):
from gui.fitCommands import (
GuiRevertMutatedLocalModuleCommand, GuiRevertMutatedLocalDroneCommand)
fitID = self.mainFrame.getActiveFit()
fit = Fit.getInstance().getFit(fitID)
if mainItem in fit.modules:

View File

@@ -3,7 +3,7 @@ from logbook import Logger
import gui.builtinMarketBrowser.pfSearchBox as SBox
import gui.globalEvents as GE
from config import slotColourMap, slotColourMapDark
from config import get_slotColourMap, get_slotColourMapDark
from eos.saveddata.module import Module
from eos.const import FittingSlot
from gui.builtinMarketBrowser.events import ItemSelected, RECENTLY_USED_MODULES, CHARGES_FOR_FIT
@@ -412,7 +412,7 @@ class ItemView(Display):
def columnBackground(self, colItem, item):
if self.sFit.serviceFittingOptions["colorFitBySlot"]:
colorMap = slotColourMapDark if isDark() else slotColourMap
colorMap = get_slotColourMapDark() if isDark() else get_slotColourMap()
return colorMap.get(Module.calculateSlot(item)) or self.GetBackgroundColour()
else:
return self.GetBackgroundColour()

View File

@@ -42,7 +42,7 @@ from gui.utils.staticHelpers import DragDropHelper
from gui.utils.dark import isDark
from service.fit import Fit
from service.market import Market
from config import slotColourMap, slotColourMapDark, errColor, errColorDark
from config import get_slotColourMap, get_slotColourMapDark, get_errColor, get_errColorDark
from gui.fitCommands.helpers import getSimilarModPositions
pyfalog = Logger(__name__)
@@ -766,9 +766,9 @@ class FittingView(d.Display):
def slotColour(self, slot):
if isDark():
return slotColourMapDark.get(slot) or self.GetBackgroundColour()
return get_slotColourMapDark().get(slot) or self.GetBackgroundColour()
else:
return slotColourMap.get(slot) or self.GetBackgroundColour()
return get_slotColourMap().get(slot) or self.GetBackgroundColour()
def refresh(self, stuff):
"""
@@ -812,7 +812,7 @@ class FittingView(d.Display):
hasRestrictionOverriden = not hasRestrictionOverriden
if slotMap[mod.slot] or hasRestrictionOverriden: # Color too many modules as red
self.SetItemBackgroundColour(i, errColorDark if isDark() else errColor)
self.SetItemBackgroundColour(i, get_errColorDark() if isDark() else get_errColor())
elif sFit.serviceFittingOptions["colorFitBySlot"]: # Color by slot it enabled
self.SetItemBackgroundColour(i, self.slotColour(mod.slot))

View File

@@ -346,20 +346,7 @@ class CargoInfo:
return makeReprStr(self, ['itemID', 'amount'])
def activeStateLimit(itemIdentity):
item = Market.getInstance().getItem(itemIdentity)
if {
'moduleBonusAssaultDamageControl', 'moduleBonusIndustrialInvulnerability',
'microJumpDrive', 'microJumpPortalDrive', 'emergencyHullEnergizer',
'cynosuralGeneration', 'jumpPortalGeneration', 'jumpPortalGenerationBO',
'cloneJumpAccepting', 'cloakingWarpSafe', 'cloakingPrototype', 'cloaking',
'massEntanglerEffect5', 'electronicAttributeModifyOnline', 'targetPassively',
'cargoScan', 'shipScan', 'surveyScan', 'targetSpectrumBreakerBonus',
'interdictionNullifierBonus', 'warpCoreStabilizerActive',
'industrialItemCompression'
}.intersection(item.effects):
return FittingModuleState.ONLINE
return FittingModuleState.ACTIVE
from service.port.active_state import activeStateLimit
def droneStackLimit(fit, itemIdentity):

View File

@@ -79,8 +79,20 @@ a = Analysis(['pyfa.py'],
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
a_server = Analysis(['pyfa_server.py'],
pathex=pathex,
binaries=[],
datas=added_files,
hiddenimports=import_these,
hookspath=['dist_assets/pyinstaller_hooks'],
runtime_hooks=[],
excludes=['Tkinter'],
win_no_prefer_redirects=False,
win_private_assemblies=False,
cipher=block_cipher)
pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher)
pyz_server = PYZ(a_server.pure, a_server.zipped_data, cipher=block_cipher)
exe = EXE(
pyz,
@@ -96,6 +108,19 @@ exe = EXE(
contents_directory='app',
)
# Sim server. POST /simulate on port 9123.
exe_server = EXE(
pyz_server,
a_server.scripts,
exclude_binaries=True,
name='pyfa-server',
debug=debug,
strip=False,
upx=upx,
console=True,
contents_directory='app',
)
coll = COLLECT(
exe,
a.binaries,
@@ -106,6 +131,16 @@ coll = COLLECT(
name='pyfa',
)
coll_server = COLLECT(
exe_server,
a_server.binaries,
a_server.zipfiles,
a_server.datas,
strip=False,
upx=upx,
name='pyfa_server',
)
if platform.system() == 'Darwin':
info_plist = {
'NSHighResolutionCapable': 'True',

5
pyfa_server.py Normal file
View File

@@ -0,0 +1,5 @@
#!/usr/bin/env python3
# Sim server. POST /simulate with JSON body.
from scripts.pyfa_sim import _run_http_server
_run_http_server(9123, None)

View File

@@ -22,3 +22,6 @@ dependencies = [
"sqlalchemy==1.4.50",
"wxpython==4.2.1",
]
[project.optional-dependencies]
dev = ["pytest"]

View File

@@ -57,4 +57,32 @@ curl -X POST \
rm "${ZIP}"
# Push Docker image
DOCKER_REPO="${DOCKER_REPO:-docker.site.quack-lab.dev}"
IMAGE_NAME="${IMAGE_NAME:-pyfa-server}"
COMMIT_SHA=$(git rev-parse --short HEAD)
IMAGE_BASE="${DOCKER_REPO}/${IMAGE_NAME}"
echo ""
echo "Pushing Docker images..."
docker push "${IMAGE_BASE}:${COMMIT_SHA}"
docker push "${IMAGE_BASE}:latest"
TAGS=$(git tag --points-at HEAD 2>/dev/null || true)
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && docker push "${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi
echo ""
echo "Docker image pushed as:"
echo " - ${IMAGE_BASE}:${COMMIT_SHA}"
echo " - ${IMAGE_BASE}:latest"
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && echo " - ${IMAGE_BASE}:${tag}"
done <<< "$TAGS"
fi
echo ""
echo "Release complete! ${ZIP} uploaded to ${TAG}"

14
requirements-server.txt Normal file
View File

@@ -0,0 +1,14 @@
logbook==1.7.0.post0
numpy==1.26.2
matplotlib==3.8.2
python-dateutil==2.8.2
requests==2.31.0
sqlalchemy==1.4.50
cryptography==42.0.4
markdown2==2.4.11
packaging==23.2
roman==4.1
beautifulsoup4==4.12.2
pyyaml==6.0.1
python-jose==3.3.0
requests-cache==1.1.1

460
scripts/pyfa_sim.py Normal file
View File

@@ -0,0 +1,460 @@
import json
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
import config
import eos.config
from eos.const import FittingHardpoint
from eos.utils.spoolSupport import SpoolOptions, SpoolType
# POST /simulate request and response shape; included in 400 response body
SIMULATE_SCHEMA = {
"request": {
"fit": "string (required). EFT or multi-line text export of a single fit, e.g. [ShipName, FitName] followed by modules/drones/etc.",
"projected_fits": "array (optional). Each element: { \"fit\": string, \"count\": positive integer }.",
"command_fits": "array (optional). Each element: { \"fit\": string }.",
},
"response_200": {
"fit": {"id": "int", "name": "string", "ship_type": "string"},
"resources": {"hardpoints": {}, "drones": {}, "fighters": {}, "calibration": {}, "powergrid": {}, "cpu": {}, "cargo": {}},
"defense": {"hp": {}, "ehp": {}, "resonance": {}, "tank": {}, "effective_tank": {}, "sustainable_tank": {}, "effective_sustainable_tank": {}},
"capacitor": {"capacity": {}, "recharge": {}, "use": {}, "delta": {}, "stable": {}, "state": {}, "neutralizer_resistance": {}},
"firepower": {"weapon_dps": {}, "drone_dps": {}, "total_dps": {}, "weapon_volley": {}, "drone_volley": {}, "total_volley": {}, "weapons": []},
"remote_reps_outgoing": {"current": {}, "pre_spool": {}, "full_spool": {}},
"targeting_misc": {"targets_max": {}, "target_range": {}, "scan_resolution": {}, "scan_strength": {}, "scan_type": {}, "jam_chance": {}, "drone_control_range": {}, "speed": {}, "align_time": {}, "signature_radius": {}, "warp_speed": {}, "max_warp_distance": {}, "probe_size": {}, "cargo_capacity": {}, "cargo_used": {}},
"price": {"ship": {}, "fittings": {}, "drones_and_fighters": {}, "cargo": {}, "character": {}, "total": {}},
},
}
def _init_pyfa(savepath: str | None) -> None:
config.debug = False
config.loggingLevel = config.LOGLEVEL_MAP["error"]
config.defPaths(savepath)
config.defLogging()
import eos.db # noqa: F401
eos.db.saveddata_meta.create_all() # type: ignore[name-defined]
import eos.events # noqa: F401
from service import prefetch # noqa: F401
def _parse_drone_markers(text: str) -> tuple[str, set[str]]:
active_names: set[str] = set()
cleaned_lines: list[str] = []
for raw_line in text.splitlines():
line = raw_line.rstrip()
if " xx" in line:
idx = line.find(" xx")
marker_segment = line[idx + 1 :].strip()
if marker_segment == "xx":
payload = line[:idx].rstrip()
parts = payload.split(" x", 1)
if len(parts) == 2:
type_name = parts[0].strip()
if type_name:
active_names.add(type_name)
line = payload
cleaned_lines.append(line)
return "\n".join(cleaned_lines), active_names
def _import_single_fit(raw_text: str) -> tuple[object, set[str]]:
from service.port.port import Port
cleaned_text, active_names = _parse_drone_markers(raw_text)
import_type, import_data = Port.importFitFromBuffer(cleaned_text)
if not import_data or len(import_data) != 1:
raise ValueError("Expected exactly one fit in input; got %d" % (len(import_data) if import_data else 0))
fit = import_data[0]
if active_names:
for drone in fit.drones:
if getattr(drone.item, "typeName", None) in active_names:
drone.amountActive = drone.amount
for fighter in fit.fighters:
if getattr(fighter.item, "typeName", None) in active_names:
fighter.amount = fighter.amount
return fit, active_names
def _add_projected_fit(s_fit, target_fit, projected_fit, amount: int) -> None:
fit = s_fit.getFit(target_fit.ID)
projected = s_fit.getFit(projected_fit.ID, projected=True)
if projected is None:
raise ValueError("Projected fit %s is not available" % projected_fit.ID)
if projected in fit.projectedFits and projected.ID in fit.projectedFitDict:
projection_info = projected.getProjectionInfo(fit.ID)
if projection_info is None:
raise ValueError("Projection info missing for projected fit %s" % projected_fit.ID)
else:
fit.projectedFitDict[projected.ID] = projected
eos.db.saveddata_session.flush()
eos.db.saveddata_session.refresh(projected)
projection_info = projected.getProjectionInfo(fit.ID)
if projection_info is None:
raise ValueError("Projection info missing after linking projected fit %s" % projected_fit.ID)
projection_info.amount = amount
projection_info.active = True
def _add_command_fit(s_fit, target_fit, command_fit) -> None:
fit = s_fit.getFit(target_fit.ID)
command = s_fit.getFit(command_fit.ID)
if command is None:
raise ValueError("Command fit %s is not available" % command_fit.ID)
if command in fit.commandFits or command.ID in fit.commandFitDict:
return
fit.commandFitDict[command.ID] = command
eos.db.saveddata_session.flush()
eos.db.saveddata_session.refresh(command)
info = command.getCommandInfo(fit.ID)
if info is None:
raise ValueError("Command info missing for command fit %s" % command_fit.ID)
info.active = True
def _collect_resources(fit) -> dict:
ship = fit.ship
resources: dict = {}
resources["hardpoints"] = {
"turret": {
"used": fit.getHardpointsUsed(FittingHardpoint.TURRET),
"total": ship.getModifiedItemAttr("turretSlotsLeft"),
},
"launcher": {
"used": fit.getHardpointsUsed(FittingHardpoint.MISSILE),
"total": ship.getModifiedItemAttr("launcherSlotsLeft"),
},
}
resources["drones"] = {
"active": fit.activeDrones,
"max_active": fit.extraAttributes.get("maxActiveDrones"),
"bay_used": fit.droneBayUsed,
"bay_capacity": ship.getModifiedItemAttr("droneCapacity"),
"bandwidth_used": fit.droneBandwidthUsed,
"bandwidth_capacity": ship.getModifiedItemAttr("droneBandwidth"),
}
resources["fighters"] = {
"tubes_used": fit.fighterTubesUsed,
"tubes_total": fit.fighterTubesTotal,
"bay_used": fit.fighterBayUsed,
"bay_capacity": ship.getModifiedItemAttr("fighterCapacity"),
}
resources["calibration"] = {
"used": fit.calibrationUsed,
"total": ship.getModifiedItemAttr("upgradeCapacity"),
}
resources["powergrid"] = {
"used": fit.pgUsed,
"output": ship.getModifiedItemAttr("powerOutput"),
}
resources["cpu"] = {
"used": fit.cpuUsed,
"output": ship.getModifiedItemAttr("cpuOutput"),
}
resources["cargo"] = {
"used": fit.cargoBayUsed,
"capacity": ship.getModifiedItemAttr("capacity"),
}
return resources
def _collect_defense(fit) -> dict:
ship = fit.ship
def _res(attr_name):
return ship.getModifiedItemAttr(attr_name)
resonance = {
"armor": {"em": _res("armorEmDamageResonance"), "exp": _res("armorExplosiveDamageResonance"), "kin": _res("armorKineticDamageResonance"), "therm": _res("armorThermalDamageResonance")},
"shield": {"em": _res("shieldEmDamageResonance"), "exp": _res("shieldExplosiveDamageResonance"), "kin": _res("shieldKineticDamageResonance"), "therm": _res("shieldThermalDamageResonance")},
"hull": {"em": _res("emDamageResonance"), "exp": _res("explosiveDamageResonance"), "kin": _res("kineticDamageResonance"), "therm": _res("thermalDamageResonance")},
}
defense: dict = {}
defense["hp"] = fit.hp
defense["ehp"] = fit.ehp
defense["resonance"] = resonance
defense["tank"] = fit.tank
defense["effective_tank"] = fit.effectiveTank
defense["sustainable_tank"] = fit.sustainableTank
defense["effective_sustainable_tank"] = fit.effectiveSustainableTank
return defense
def _collect_capacitor(fit) -> dict:
ship = fit.ship
cap: dict = {}
cap["capacity"] = ship.getModifiedItemAttr("capacitorCapacity")
cap["recharge"] = fit.capRecharge
cap["use"] = fit.capUsed
cap["delta"] = fit.capDelta
cap["stable"] = fit.capStable
cap["state"] = fit.capState
cap["neutralizer_resistance"] = ship.getModifiedItemAttr("energyWarfareResistance", 1)
return cap
def _collect_firepower(fit) -> dict:
default_spool = eos.config.settings["globalDefaultSpoolupPercentage"]
spool = SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False)
wdps = fit.getWeaponDps(spoolOptions=spool)
ddps = fit.getDroneDps()
wvol = fit.getWeaponVolley(spoolOptions=spool)
dvol = fit.getDroneVolley()
return {
"weapon_dps": wdps.total,
"drone_dps": ddps.total,
"total_dps": fit.getTotalDps(spoolOptions=spool).total,
"weapon_volley": wvol.total,
"drone_volley": dvol.total,
"total_volley": fit.getTotalVolley(spoolOptions=spool).total,
"weapons": [],
}
def _collect_remote_reps(fit) -> dict:
default_spool = eos.config.settings["globalDefaultSpoolupPercentage"]
pre = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 0, True))
full = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, 1, True))
current = fit.getRemoteReps(spoolOptions=SpoolOptions(SpoolType.SPOOL_SCALE, default_spool, False))
return {
"current": {
"capacitor": current.capacitor,
"shield": current.shield,
"armor": current.armor,
"hull": current.hull,
},
"pre_spool": {
"capacitor": pre.capacitor,
"shield": pre.shield,
"armor": pre.armor,
"hull": pre.hull,
},
"full_spool": {
"capacitor": full.capacitor,
"shield": full.shield,
"armor": full.armor,
"hull": full.hull,
},
}
def _collect_targeting_misc(fit) -> dict:
ship = fit.ship
misc: dict = {}
misc["targets_max"] = fit.maxTargets
misc["target_range"] = fit.maxTargetRange
misc["scan_resolution"] = ship.getModifiedItemAttr("scanResolution")
misc["scan_strength"] = fit.scanStrength
misc["scan_type"] = fit.scanType
misc["jam_chance"] = fit.jamChance
misc["drone_control_range"] = fit.extraAttributes.get("droneControlRange")
misc["speed"] = fit.maxSpeed
misc["align_time"] = fit.alignTime
misc["signature_radius"] = ship.getModifiedItemAttr("signatureRadius")
misc["warp_speed"] = fit.warpSpeed
misc["max_warp_distance"] = fit.maxWarpDistance
misc["probe_size"] = fit.probeSize
misc["cargo_capacity"] = ship.getModifiedItemAttr("capacity")
misc["cargo_used"] = fit.cargoBayUsed
return misc
def _collect_price(fit) -> dict:
ship_price = 0.0
module_price = 0.0
drone_price = 0.0
fighter_price = 0.0
cargo_price = 0.0
booster_price = 0.0
implant_price = 0.0
if fit:
ship_price = getattr(fit.ship.item.price, "price", 0.0)
for module in fit.modules:
if not module.isEmpty:
module_price += getattr(module.item.price, "price", 0.0)
for drone in fit.drones:
drone_price += getattr(drone.item.price, "price", 0.0) * drone.amount
for fighter in fit.fighters:
fighter_price += getattr(fighter.item.price, "price", 0.0) * fighter.amount
for cargo in fit.cargo:
cargo_price += getattr(cargo.item.price, "price", 0.0) * cargo.amount
for booster in fit.boosters:
booster_price += getattr(booster.item.price, "price", 0.0)
for implant in fit.appliedImplants:
implant_price += getattr(implant.item.price, "price", 0.0)
total_price = ship_price + module_price + drone_price + fighter_price + cargo_price + booster_price + implant_price
return {
"ship": ship_price,
"fittings": module_price,
"drones_and_fighters": drone_price + fighter_price,
"cargo": cargo_price,
"character": booster_price + implant_price,
"total": total_price,
}
def _build_output(main_fit) -> dict:
return {
"fit": {
"id": main_fit.ID,
"name": main_fit.name,
"ship_type": main_fit.ship.item.typeName,
},
"resources": _collect_resources(main_fit),
"defense": _collect_defense(main_fit),
"capacitor": _collect_capacitor(main_fit),
"firepower": _collect_firepower(main_fit),
"remote_reps_outgoing": _collect_remote_reps(main_fit),
"targeting_misc": _collect_targeting_misc(main_fit),
"price": _collect_price(main_fit),
}
def compute_stats(payload: dict, savepath: str | None = None) -> dict:
if "fit" not in payload or not isinstance(payload["fit"], str):
raise ValueError("Payload must contain a 'fit' field with EFT/text export")
_init_pyfa(savepath)
from service.fit import Fit as FitService
s_fit = FitService.getInstance()
if s_fit.character is None:
from eos.saveddata.character import Character as saveddata_Character
s_fit.character = saveddata_Character.getAll5()
main_fit, _ = _import_single_fit(payload["fit"])
projected_defs = payload.get("projected_fits", [])
command_defs = payload.get("command_fits", [])
projected_fits: list[tuple[object, int]] = []
for entry in projected_defs:
if not isinstance(entry, dict):
raise ValueError("Each projected_fits entry must be an object")
fit_text = entry.get("fit")
count = entry.get("count")
if not isinstance(fit_text, str):
raise ValueError("Each projected_fits entry must contain a string 'fit'")
if not isinstance(count, int) or count <= 0:
raise ValueError("Each projected_fits entry must contain a positive integer 'count'")
pf, _ = _import_single_fit(fit_text)
projected_fits.append((pf, count))
command_fits: list[object] = []
for entry in command_defs:
if not isinstance(entry, dict):
raise ValueError("Each command_fits entry must be an object")
fit_text = entry.get("fit")
if not isinstance(fit_text, str):
raise ValueError("Each command_fits entry must contain a string 'fit'")
cf, _ = _import_single_fit(fit_text)
command_fits.append(cf)
for pf, count in projected_fits:
_add_projected_fit(s_fit, main_fit, pf, count)
for cf in command_fits:
_add_command_fit(s_fit, main_fit, cf)
s_fit.recalc(main_fit)
s_fit.fill(main_fit)
return _build_output(main_fit)
def _run_http_server(port: int, savepath: str | None) -> None:
def send_error_json(code: int, msg: str, traceback_str: str | None = None):
body = {"error": msg, "schema": SIMULATE_SCHEMA}
if traceback_str:
body["traceback"] = traceback_str
return json.dumps(body, indent=2) + "\n"
class SimulateHandler(BaseHTTPRequestHandler):
def end_headers(self):
self.send_header("Access-Control-Allow-Origin", "*")
super().end_headers()
def send_error(self, code: int, message: str | None = None, explain: str | None = None):
msg = message or explain or "Error"
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(send_error_json(code, msg).encode("utf-8"))
def _reply_error(self, code: int, msg: str, traceback_str: str | None = None):
self.send_response(code)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(send_error_json(code, msg, traceback_str).encode("utf-8"))
def do_OPTIONS(self):
self.send_response(204)
self.send_header("Access-Control-Allow-Methods", "POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.send_header("Access-Control-Max-Age", "86400")
self.end_headers()
def do_GET(self):
if self.path == "/simulate":
self._reply_error(405, "Method not allowed. Use POST.")
else:
self._reply_error(404, "Not found. POST /simulate with JSON body.")
def do_POST(self):
if self.path != "/simulate":
self._reply_error(404, "Not found. POST /simulate with JSON body.")
return
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length).decode("utf-8")
try:
payload = json.loads(body)
except json.JSONDecodeError as exc:
self._reply_error(400, "Invalid JSON: %s" % exc)
return
if not isinstance(payload, dict):
self._reply_error(400, "Top-level JSON must be an object")
return
try:
output = compute_stats(payload, savepath)
except ValueError as e:
self._reply_error(400, str(e))
return
except Exception as e:
import traceback
tb = traceback.format_exc()
sys.stderr.write(tb)
sys.stderr.flush()
self._reply_error(500, str(e), tb)
return
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.end_headers()
self.wfile.write(json.dumps(output, indent=2, sort_keys=True).encode("utf-8"))
self.wfile.write(b"\n")
with HTTPServer(("", port), SimulateHandler) as httpd:
print("POST /simulate on http://127.0.0.1:%s" % port, file=sys.stderr, flush=True)
httpd.serve_forever()
if __name__ == "__main__":
_run_http_server(9123, None)

View File

@@ -28,12 +28,8 @@ from xml.etree import ElementTree
from xml.dom import minidom
import gzip
# noinspection PyPackageRequirements
import wx
import config
import eos.db
from service.esi import Esi
from eos.saveddata.implant import Implant as es_Implant
from eos.saveddata.character import Character as es_Character, Skill
@@ -42,7 +38,12 @@ from eos.const import FittingSlot as es_Slot
from eos.saveddata.fighter import Fighter as es_Fighter
pyfalog = Logger(__name__)
_t = wx.GetTranslation
def _t(s):
import wx
return wx.GetTranslation(s)
class CharacterImportThread(threading.Thread):
@@ -97,6 +98,7 @@ class CharacterImportThread(threading.Thread):
pyfalog.error(e)
continue
import wx
wx.CallAfter(self.callback)
def stop(self):
@@ -132,6 +134,7 @@ class SkillBackupThread(threading.Thread):
with open(path, mode='w', encoding='utf-8') as backupFile:
backupFile.write(backupData)
import wx
wx.CallAfter(self.callback)
def stop(self):
@@ -386,6 +389,7 @@ class Character:
def apiFetchCallback(self, guiCallback, e=None):
eos.db.commit()
import wx
wx.CallAfter(guiCallback, e)
@staticmethod
@@ -501,6 +505,7 @@ class UpdateAPIThread(threading.Thread):
try:
char = eos.db.getCharacter(self.charID)
from service.esi import Esi
sEsi = Esi.getInstance()
sChar = Character.getInstance()
ssoChar = sChar.getSsoCharacter(char.ID)

View File

@@ -13,11 +13,9 @@ from service.const import EsiLoginMethod, EsiSsoMode
from eos.saveddata.ssocharacter import SsoCharacter
from service.esiAccess import APIException, GenericSsoError
import gui.globalEvents as GE
from gui.ssoLogin import SsoLogin
from service.server import StoppableHTTPServer, AuthHandler
from service.settings import EsiSettings
from service.esiAccess import EsiAccess
import gui.mainFrame
from requests import Session
@@ -140,6 +138,7 @@ class Esi(EsiAccess):
self.fittings_deleted.add(fittingID)
def login(self):
import gui.ssoLogin
start_server = self.settings.get('loginMode') == EsiLoginMethod.SERVER and self.server_base.supports_auto_login
with gui.ssoLogin.SsoLogin(self.server_base, start_server) as dlg:
if dlg.ShowModal() == wx.ID_OK:

View File

@@ -22,7 +22,6 @@ import datetime
from time import time
from weakref import WeakSet
import wx
from logbook import Logger
import eos.db
@@ -235,6 +234,7 @@ class Fit:
@classmethod
def getCommandProcessor(cls, fitID):
if fitID not in cls.processors:
import wx
cls.processors[fitID] = wx.CommandProcessor(maxCommands=100)
return cls.processors[fitID]

View File

@@ -23,8 +23,6 @@ import threading
from collections import OrderedDict
from itertools import chain
# noinspection PyPackageRequirements
import wx
from logbook import Logger
from sqlalchemy.sql import or_
@@ -38,7 +36,12 @@ from service.settings import SettingsProvider
from utils.cjk import isStringCjk
pyfalog = Logger(__name__)
_t = wx.GetTranslation
def _t(s):
import wx
return wx.GetTranslation(s)
# Event which tells threads dependent on Market that it's initialized
mktRdy = threading.Event()
@@ -77,6 +80,7 @@ class ShipBrowserWorkerThread(threading.Thread):
set_ = sMkt.getShipList(id_)
cache[id_] = set_
import wx
wx.CallAfter(callback, (id_, set_))
except (KeyboardInterrupt, SystemExit):
raise
@@ -170,6 +174,7 @@ class SearchWorkerThread(threading.Thread):
for item in all_results:
if sMkt.getPublicityByItem(item):
item_IDs.add(item.ID)
import wx
wx.CallAfter(callback, sorted(item_IDs))
def scheduleSearch(self, text, callback, filterName=None):
@@ -268,7 +273,7 @@ class Market:
self.les_grp = types_Group()
self.les_grp.ID = -1
self.les_grp.name = "Limited Issue Ships"
self.les_grp.displayName = _t("Limited Issue Ships")
self.les_grp.displayName = "Limited Issue Ships"
self.les_grp.published = True
ships = self.getCategory("Ship")
self.les_grp.category = ships

View File

@@ -1,2 +1,8 @@
from .efs import EfsPort
from .port import Port
def __getattr__(name):
if name == "Port":
from service.port.port import Port
return Port
raise AttributeError("module %r has no attribute %r" % (__name__, name))

View File

@@ -0,0 +1,12 @@
# Module-state logic shared by port (EFT import) and gui (fit commands).
# Uses gamedata (item type + attributes), no hardcoded effect names.
from eos.const import FittingModuleState
from service.market import Market
def activeStateLimit(itemIdentity):
item = Market.getInstance().getItem(itemIdentity)
if not item.isType("active") or item.getAttribute("activationBlocked", 0) > 0:
return FittingModuleState.ONLINE
return FittingModuleState.ACTIVE

View File

@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit
from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortDnaOptions
from service.fit import Fit as svcFit
from service.market import Market
@@ -80,6 +79,7 @@ def importDnaAlt(string, fitName=None):
return processImportInfo(info, fitName, "*")
def processImportInfo(info, fitName, amountSeparator):
from gui.fitCommands.helpers import activeStateLimit
sMkt = Market.getInstance()
f = Fit()
try:

View File

@@ -16,10 +16,6 @@ from eos.effectHandlerHelpers import HandledList
from eos.db import gamedata_session, getCategory, getAttributeInfo, getGroup
from eos.gamedata import Attribute, Effect, Group, Item, ItemEffect
from eos.utils.spoolSupport import SpoolType, SpoolOptions
from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
from gui.fitCommands.helpers import ModuleInfo
pyfalog = Logger(__name__)
@@ -68,6 +64,9 @@ class EfsPort:
if propID is None:
return None
from gui.fitCommands.calc.module.localAdd import CalcAddLocalModuleCommand
from gui.fitCommands.calc.module.localRemove import CalcRemoveLocalModulesCommand
from gui.fitCommands.helpers import ModuleInfo
cmd = CalcAddLocalModuleCommand(fitID, ModuleInfo(itemID=propID))
cmd.Do()
if cmd.needsGuiRecalc:
@@ -137,6 +136,7 @@ class EfsPort:
EfsPort.attrDirectMap(["reloadTime"], stats, mod)
c = mod.charge
if c:
from gui.fitCommands.calc.module.changeCharges import CalcChangeModuleChargesCommand
sFit.recalc(fit)
CalcChangeModuleChargesCommand(
fit.ID,

View File

@@ -33,7 +33,6 @@ from eos.saveddata.fit import Fit
from eos.saveddata.implant import Implant
from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.const import PortEftOptions
from service.fit import Fit as svcFit
from service.market import Market
@@ -48,6 +47,8 @@ SLOT_ORDER = (FittingSlot.LOW, FittingSlot.MED, FittingSlot.HIGH, FittingSlot.RI
OFFLINE_SUFFIX = '/offline'
NAME_CHARS = r'[^,/\[\]]' # Characters which are allowed to be used in name
from service.port.active_state import activeStateLimit
class MutationExportData:

View File

@@ -31,7 +31,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit
from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.fit import Fit as svcFit
from service.market import Market
@@ -161,6 +160,7 @@ def exportESI(ofit, exportCharges, exportImplants, exportBoosters, callback):
def importESI(string):
from gui.fitCommands.helpers import activeStateLimit
sMkt = Market.getInstance()
fitobj = Fit()

View File

@@ -38,7 +38,6 @@ from service.port.eft import (
isValidImplantImport, isValidBoosterImport)
from service.port.esi import exportESI, importESI
from service.port.multibuy import exportMultiBuy
from service.port.shipstats import exportFitStats
from service.port.xml import importXml, exportXml
from service.port.muta import parseMutant, parseDynamicItemString, fetchDynamicItem
@@ -195,10 +194,14 @@ class Port:
# TODO: catch the exception?
# activeFit is reserved?, bufferStr is unicode? (assume only clipboard string?
sFit = svcFit.getInstance()
if sFit.character is None:
from eos.saveddata.character import Character as saveddata_Character
sFit.character = saveddata_Character.getAll5()
importType, makesNewFits, importData = Port.importAuto(bufferStr, activeFit=activeFit)
if makesNewFits:
for fit in importData:
fits = [f for f in importData if f is not None]
for fit in fits:
fit.character = sFit.character
fit.damagePattern = sFit.pattern
fit.targetProfile = sFit.targetProfile
@@ -208,6 +211,7 @@ class Port:
useCharImplants = sFit.serviceFittingOptions["useCharacterImplantsByDefault"]
fit.implantLocation = ImplantLocation.CHARACTER if useCharImplants else ImplantLocation.FIT
db.save(fit)
return importType, fits
return importType, importData
@classmethod
@@ -341,4 +345,5 @@ class Port:
@staticmethod
def exportFitStats(fit, callback=None):
return exportFitStats(fit, callback=callback)
from service.port.shipstats import exportFitStats as _exportFitStats
return _exportFitStats(fit, callback=callback)

View File

@@ -32,7 +32,6 @@ from eos.saveddata.fighter import Fighter
from eos.saveddata.fit import Fit
from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from gui.fitCommands.helpers import activeStateLimit
from service.fit import Fit as svcFit
from service.market import Market
from service.port.muta import renderMutantAttrs, parseMutantAttrs
@@ -155,6 +154,7 @@ def _resolve_module(hardware, sMkt, b_localized):
def importXml(text, progress):
from gui.fitCommands.helpers import activeStateLimit
from .port import Port
sMkt = Market.getInstance()
doc = xml.dom.minidom.parseString(text)

View File

@@ -24,7 +24,6 @@ import timeit
from itertools import chain
import math
import wx
from logbook import Logger
from eos import db
@@ -51,6 +50,8 @@ class Price:
sources = {}
def __init__(self):
# Import market sources so they register; avoid at module level so server path never loads them
from service.marketSources import evemarketdata, fuzzwork, cevemarket, evetycoon # noqa: F401
# Start price fetcher
self.priceWorkerThread = PriceWorkerThread()
self.priceWorkerThread.daemon = True
@@ -253,6 +254,7 @@ class PriceWorkerThread(threading.Thread):
if len(requests) > 0:
Price.fetchPrices(requests, fetchTimeout, validityOverride)
import wx
wx.CallAfter(callback)
queue.task_done()
@@ -273,7 +275,3 @@ class PriceWorkerThread(threading.Thread):
def stop(self):
self.running = False
# Import market sources only to initialize price source modules, they register on their own
from service.marketSources import evemarketdata, fuzzwork, cevemarket, evetycoon # noqa: E402

View File

@@ -24,7 +24,6 @@ import urllib.error
import urllib.parse
import json
from collections import namedtuple
import wx
from logbook import Logger
@@ -582,6 +581,7 @@ class LocaleSettings:
@classmethod
def supported_languages(cls):
"""Requires the application to be initialized, otherwise wx.Translation isn't set."""
import wx
pyfalog.info(f'using "{config.CATALOG}" to fetch languages, relatively base path "{os.getcwd()}"')
return {x: wx.Locale.FindLanguageInfo(x) for x in wx.Translations.Get().GetAvailableTranslations(config.CATALOG)}

46
tests/test_pyfa_sim.py Normal file
View File

@@ -0,0 +1,46 @@
import os
import sys
import tempfile
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.append(os.path.realpath(os.path.join(script_dir, "..")))
from scripts import pyfa_sim # noqa: E402
ISHTAR_SPIDER_FIT = """[Ishtar, Spider]
Capacitor Power Relay II
Drone Damage Amplifier II
Explosive Armor Hardener II
Multispectrum Energized Membrane II
Reactive Armor Hardener
Shadow Serpentis EM Armor Hardener
Cap Recharger II
Omnidirectional Tracking Link II, Tracking Speed Script
Medium Compact Pb-Acid Cap Battery
Republic Fleet Large Cap Battery
Medium Remote Armor Repairer II
Medium Remote Armor Repairer II
Medium Remote Armor Repairer II
Medium Remote Armor Repairer II
Medium Explosive Armor Reinforcer II
Medium Thermal Armor Reinforcer II
Valkyrie II x5
Berserker II x5
"""
def test_ishtar_spider_remote_armor_reps():
payload = {"fit": ISHTAR_SPIDER_FIT}
with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmp:
data = pyfa_sim.compute_stats(payload, tmp)
armor_rps = data["remote_reps_outgoing"]["current"]["armor"]
assert armor_rps is not None
assert int(round(armor_rps)) == 171

59
uv.lock generated
View File

@@ -159,6 +159,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "contourpy"
version = "1.3.3"
@@ -369,6 +378,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "kiwisolver"
version = "1.4.9"
@@ -593,6 +611,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.1"
@@ -634,6 +661,11 @@ dependencies = [
{ name = "wxpython" },
]
[package.optional-dependencies]
dev = [
{ name = "pytest" },
]
[package.metadata]
requires-dist = [
{ name = "beautifulsoup4", specifier = "==4.12.2" },
@@ -643,6 +675,7 @@ requires-dist = [
{ name = "matplotlib", specifier = "==3.8.2" },
{ name = "numpy", specifier = "==1.26.2" },
{ name = "packaging", specifier = "==23.2" },
{ name = "pytest", marker = "extra == 'dev'" },
{ name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-jose", specifier = "==3.3.0" },
{ name = "pyyaml", specifier = "==6.0.1" },
@@ -653,6 +686,16 @@ requires-dist = [
{ name = "sqlalchemy", specifier = "==1.4.50" },
{ name = "wxpython", specifier = "==4.2.1" },
]
provides-extras = ["dev"]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pyparsing"
@@ -663,6 +706,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/40/2614036cdd416452f5bf98ec037f38a1afb17f327cb8e6b652d4729e0af8/pyparsing-3.3.1-py3-none-any.whl", hash = "sha256:023b5e7e5520ad96642e2c6db4cb683d3970bd640cdf7115049a6e9c3682df82", size = 121793, upload-time = "2025-12-23T03:14:02.103Z" },
]
[[package]]
name = "pytest"
version = "9.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" },
]
[[package]]
name = "python-dateutil"
version = "2.8.2"