Merge pull request #1857 from pyfa-org/price_optimize

Price optimize
This commit is contained in:
Anton Vorobyov
2019-03-19 19:48:36 +03:00
committed by GitHub
47 changed files with 623 additions and 341 deletions

View File

@@ -95,7 +95,7 @@ def defPaths(customSavePath=None):
global pyfaPath
global savePath
global saveDB
global gameDB
global gameDB
global saveInRoot
global logPath
global cipher

View File

@@ -41,8 +41,7 @@ items_table = Table("invtypes", gamedata_meta,
Column("iconID", Integer),
Column("graphicID", Integer),
Column("groupID", Integer, ForeignKey("invgroups.groupID"), index=True),
Column("replaceSame", String),
Column("replaceBetter", String))
Column("replacements", String))
from .metaGroup import metatypes_table # noqa
from .traits import traits_table # noqa

View File

@@ -32,6 +32,4 @@ prices_table = Table("prices", saveddata_meta,
Column("status", Integer, nullable=False))
mapper(Price, prices_table, properties={
"_Price__price": prices_table.c.price,
})
mapper(Price, prices_table)

View File

@@ -30,6 +30,7 @@ pyfalog = Logger(__name__)
class Booster(HandledItem, ItemAttrShortcut):
def __init__(self, item):
self.__item = item
@@ -147,3 +148,17 @@ class Booster(HandledItem, ItemAttrShortcut):
copyEffect.active = sideEffect.active
return copy
def rebase(self, item):
active = self.active
sideEffectStates = {se.effectID: se.active for se in self.sideEffects}
Booster.__init__(self, item)
self.active = active
for sideEffect in self.sideEffects:
if sideEffect.effectID in sideEffectStates:
sideEffect.active = sideEffectStates[sideEffect.effectID]
def __repr__(self):
return "Booster(ID={}, name={}) at {}".format(
self.item.ID, self.item.name, hex(id(self))
)

View File

@@ -89,3 +89,13 @@ class Cargo(HandledItem, ItemAttrShortcut):
copy = Cargo(self.item)
copy.amount = self.amount
return copy
def rebase(self, item):
amount = self.amount
Cargo.__init__(self, item)
self.amount = amount
def __repr__(self):
return "Cargo(ID={}, name={}) at {}".format(
self.item.ID, self.item.name, hex(id(self))
)

View File

@@ -296,6 +296,13 @@ class Drone(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut):
copy.amountActive = self.amountActive
return copy
def rebase(self, item):
amount = self.amount
amountActive = self.amountActive
Drone.__init__(self, item)
self.amount = amount
self.amountActive = amountActive
def fits(self, fit):
fitDroneGroupLimits = set()
for i in range(1, 3):

View File

@@ -355,6 +355,17 @@ class Fighter(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut):
copyAbility.active = ability.active
return copy
def rebase(self, item):
amount = self.amount
active = self.active
abilityEffectStates = {a.effectID: a.active for a in self.abilities}
Fighter.__init__(self, item)
self.amount = amount
self.active = active
for ability in self.abilities:
if ability.effectID in abilityEffectStates:
ability.active = abilityEffectStates[ability.effectID]
def fits(self, fit):
# If ships doesn't support this type of fighter, don't add it
if fit.getNumSlots(self.slot) == 0:

View File

@@ -115,6 +115,11 @@ class Implant(HandledItem, ItemAttrShortcut):
copy.active = self.active
return copy
def rebase(self, item):
active = self.active
Implant.__init__(self, item)
self.active = active
def __repr__(self):
return "Implant(ID={}, name={}) at {}".format(
self.item.ID, self.item.name, hex(id(self))

View File

@@ -941,6 +941,16 @@ class Module(HandledItem, HandledCharge, ItemAttrShortcut, ChargeAttrShortcut):
return copy
def rebase(self, item):
state = self.state
charge = self.charge
Module.__init__(self, item, self.baseItem, self.mutaplasmid)
self.state = state
if self.isValidCharge(charge):
self.charge = charge
for x in self.mutators.values():
Mutator(self, x.attribute, x.value)
def __repr__(self):
if self.item:
return "Module(ID={}, name={}) at {}".format(

View File

@@ -19,41 +19,56 @@
# ===============================================================================
import time
from enum import IntEnum, unique
from time import time
from logbook import Logger
VALIDITY = 24 * 60 * 60 # Price validity period, 24 hours
REREQUEST = 4 * 60 * 60 # Re-request delay for failed fetches, 4 hours
TIMEOUT = 15 * 60 # Network timeout delay for connection issues, 15 minutes
pyfalog = Logger(__name__)
@unique
class PriceStatus(IntEnum):
notFetched = 0
success = 1
fail = 2
notSupported = 3
initialized = 0
notSupported = 1
fetchSuccess = 2
fetchFail = 3
fetchTimeout = 4
class Price(object):
def __init__(self, typeID):
self.typeID = typeID
self.time = 0
self.__price = 0
self.status = PriceStatus.notFetched
self.price = 0
self.status = PriceStatus.initialized
@property
def isValid(self):
return self.time >= time.time()
@property
def price(self):
if self.status != PriceStatus.success:
return 0
def isValid(self, validityOverride=None):
# Always attempt to update prices which were just initialized, and prices
# of unsupported items (maybe we start supporting them at some point)
if self.status in (PriceStatus.initialized, PriceStatus.notSupported):
return False
elif self.status == PriceStatus.fetchSuccess:
return time() <= self.time + (validityOverride if validityOverride is not None else VALIDITY)
elif self.status == PriceStatus.fetchFail:
return time() <= self.time + REREQUEST
elif self.status == PriceStatus.fetchTimeout:
return time() <= self.time + TIMEOUT
else:
return self.__price or 0
return False
@price.setter
def price(self, price):
self.__price = price
def update(self, status, price=0):
# Keep old price if we failed to fetch new one
if status in (PriceStatus.fetchFail, PriceStatus.fetchTimeout):
price = self.price
elif status != PriceStatus.fetchSuccess:
price = 0
self.time = time()
self.price = price
self.status = status

BIN
eve.db

Binary file not shown.

View File

@@ -122,8 +122,8 @@ class FighterDisplay(d.Display):
# "Max Range",
# "Miscellanea",
"attr:maxVelocity",
"Fighter Abilities"
# "Price",
"Fighter Abilities",
"Price",
]
def __init__(self, parent):

View File

@@ -16,7 +16,7 @@ class ItemCompare(wx.Panel):
def __init__(self, parent, stuff, item, items, context=None):
# Start dealing with Price stuff to get that thread going
sPrice = ServicePrice.getInstance()
sPrice.getPrices(items, self.UpdateList)
sPrice.getPrices(items, self.UpdateList, fetchTimeout=90)
wx.Panel.__init__(self, parent)
mainSizer = wx.BoxSizer(wx.VERTICAL)

View File

@@ -22,7 +22,7 @@ import wx
from gui.statsView import StatsView
from gui.bitmap_loader import BitmapLoader
from gui.utils.numberFormatter import formatAmount
from service.price import Price
from service.price import Fit, Price
from service.settings import PriceMenuSettings
@@ -51,7 +51,7 @@ class PriceViewFull(StatsView):
gridPrice = wx.GridSizer(2, 3, 0, 0)
contentSizer.Add(gridPrice, 0, wx.EXPAND | wx.ALL, 0)
for _type in ("ship", "fittings", "total", "drones", "cargoBay", "character"):
for _type in ("ship", "fittings", "character", "drones", "cargoBay", "total"):
if _type in "ship":
image = "ship_big"
elif _type in ("fittings", "total"):
@@ -79,11 +79,8 @@ class PriceViewFull(StatsView):
def refreshPanel(self, fit):
if fit is not None:
self.fit = fit
fit_items = Price.fitItemsList(fit)
sPrice = Price.getInstance()
sPrice.getPrices(fit_items, self.processPrices)
fit_items = set(Fit.fitItemIter(fit))
Price.getInstance().getPrices(fit_items, self.processPrices, fetchTimeout=30)
self.labelEMStatus.SetLabel("Updating prices...")
self.refreshPanelPrices(fit)

View File

@@ -22,7 +22,7 @@ import wx
from gui.statsView import StatsView
from gui.bitmap_loader import BitmapLoader
from gui.utils.numberFormatter import formatAmount
from service.price import Price
from service.price import Fit, Price
from service.settings import PriceMenuSettings
@@ -73,11 +73,8 @@ class PriceViewMinimal(StatsView):
def refreshPanel(self, fit):
if fit is not None:
self.fit = fit
fit_items = Price.fitItemsList(fit)
sPrice = Price.getInstance()
sPrice.getPrices(fit_items, self.processPrices)
fit_items = set(Fit.fitItemIter(fit))
Price.getInstance().getPrices(fit_items, self.processPrices, fetchTimeout=30)
self.labelEMStatus.SetLabel("Updating prices...")
self.refreshPanelPrices(fit)

View File

@@ -22,6 +22,7 @@ import wx
from eos.saveddata.cargo import Cargo
from eos.saveddata.drone import Drone
from eos.saveddata.fighter import Fighter
from eos.saveddata.price import PriceStatus
from service.price import Price as ServicePrice
from gui.viewColumn import ViewColumn
@@ -29,6 +30,20 @@ from gui.bitmap_loader import BitmapLoader
from gui.utils.numberFormatter import formatAmount
def formatPrice(stuff, priceObj):
textItems = []
if priceObj.price:
mult = 1
if isinstance(stuff, (Drone, Cargo)):
mult = stuff.amount
elif isinstance(stuff, Fighter):
mult = stuff.amountActive
textItems.append(formatAmount(priceObj.price * mult, 3, 3, 9, currency=True))
if priceObj.status in (PriceStatus.fetchFail, PriceStatus.fetchTimeout):
textItems.append("(!)")
return " ".join(textItems)
class Price(ViewColumn):
name = "Price"
@@ -48,35 +63,21 @@ class Price(ViewColumn):
priceObj = stuff.item.price
if not priceObj.isValid:
if not priceObj.isValid():
return False
# Fetch actual price as float to not modify its value on Price object
price = priceObj.price
if price == 0:
return ""
if isinstance(stuff, Drone) or isinstance(stuff, Cargo):
price *= stuff.amount
return formatAmount(price, 3, 3, 9, currency=True)
return formatPrice(stuff, priceObj)
def delayedText(self, mod, display, colItem):
sPrice = ServicePrice.getInstance()
def callback(item):
price = item[0]
textItems = []
if price.price:
textItems.append(formatAmount(price.price, 3, 3, 9, currency=True))
if price.status == PriceStatus.fail:
textItems.append("(!)")
colItem.SetText(" ".join(textItems))
priceObj = item[0]
colItem.SetText(formatPrice(mod, priceObj))
display.SetItem(colItem)
sPrice.getPrices([mod.item], callback, True)
sPrice.getPrices([mod.item], callback, waitforthread=True)
def getImageId(self, mod):
return -1

View File

@@ -26,6 +26,10 @@ import wx
from service.port.eft import EFT_OPTIONS
from service.port.multibuy import MULTIBUY_OPTIONS
from service.settings import SettingsProvider
from service.port import EfsPort, Port
from service.const import PortMultiBuyOptions
from eos.db import getFit
from gui.utils.clipboard import toClipboard
class CopySelectDialog(wx.Dialog):
@@ -39,6 +43,17 @@ class CopySelectDialog(wx.Dialog):
def __init__(self, parent):
wx.Dialog.__init__(self, parent, id=wx.ID_ANY, title="Select a format", size=(-1, -1),
style=wx.DEFAULT_DIALOG_STYLE)
self.CopySelectDict = {
CopySelectDialog.copyFormatEft : self.exportEft,
CopySelectDialog.copyFormatXml : self.exportXml,
CopySelectDialog.copyFormatDna : self.exportDna,
CopySelectDialog.copyFormatEsi : self.exportEsi,
CopySelectDialog.copyFormatMultiBuy: self.exportMultiBuy,
CopySelectDialog.copyFormatEfs : self.exportEfs
}
self.mainFrame = parent
mainSizer = wx.BoxSizer(wx.VERTICAL)
self.copyFormats = OrderedDict((
@@ -87,8 +102,8 @@ class CopySelectDialog(wx.Dialog):
self.options[formatId][optId] = checkbox
if self.settings['options'].get(formatId, {}).get(optId, defaultFormatOptions.get(formatId, {}).get(optId)):
checkbox.SetValue(True)
bsizer.Add(checkbox, 1, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
mainSizer.Add(bsizer, 1, wx.EXPAND | wx.LEFT, 20)
bsizer.Add(checkbox, 0, wx.EXPAND | wx.TOP | wx.BOTTOM, 3)
mainSizer.Add(bsizer, 0, wx.EXPAND | wx.LEFT, 20)
buttonSizer = self.CreateButtonSizer(wx.OK | wx.CANCEL)
if buttonSizer:
@@ -99,6 +114,31 @@ class CopySelectDialog(wx.Dialog):
self.Fit()
self.Center()
def Validate(self):
# Since this dialog is shown through aa ShowModal(), we hook into the Validate function to veto the closing of the dialog until we're ready.
# This always returns False, and when we're ready will EndModal()
selected = self.GetSelected()
options = self.GetOptions()
settings = SettingsProvider.getInstance().getSettings("pyfaExport")
settings["format"] = selected
settings["options"] = options
self.waitDialog = None
def cb(text):
if self.waitDialog:
del self.waitDialog
toClipboard(text)
self.EndModal(wx.ID_OK)
export_options = options.get(selected)
if selected == CopySelectDialog.copyFormatMultiBuy and export_options.get(PortMultiBuyOptions.OPTIMIZE_PRICES, False):
self.waitDialog = wx.BusyInfo("Optimizing Prices", parent=self)
self.CopySelectDict[selected](export_options, callback=cb)
return False
def Selected(self, event):
obj = event.GetEventObject()
formatName = obj.GetLabel()
@@ -119,3 +159,27 @@ class CopySelectDialog(wx.Dialog):
for formatId in self.options:
options[formatId] = {optId: ch.IsChecked() for optId, ch in self.options[formatId].items()}
return options
def exportEft(self, options, callback):
fit = getFit(self.mainFrame.getActiveFit())
Port.exportEft(fit, options, callback)
def exportDna(self, options, callback):
fit = getFit(self.mainFrame.getActiveFit())
Port.exportDna(fit, callback)
def exportEsi(self, options, callback):
fit = getFit(self.mainFrame.getActiveFit())
Port.exportESI(fit, callback)
def exportXml(self, options, callback):
fit = getFit(self.mainFrame.getActiveFit())
Port.exportXml(None, fit, callback)
def exportMultiBuy(self, options, callback):
fit = getFit(self.mainFrame.getActiveFit())
Port.exportMultiBuy(fit, options, callback)
def exportEfs(self, options, callback):
fit = getFit(self.mainFrame.getActiveFit())
EfsPort.exportEfs(fit, 0, callback)

View File

@@ -36,3 +36,4 @@ from .guiFitRename import GuiFitRenameCommand
from .guiChangeImplantLocation import GuiChangeImplantLocation
from .guiImportMutatedModule import GuiImportMutatedModuleCommand
from .guiSetSpoolup import GuiSetSpoolup
from .guiRebaseItems import GuiRebaseItemsCommand

View File

@@ -0,0 +1,31 @@
import wx
from logbook import Logger
import eos.db
pyfalog = Logger(__name__)
class FitRebaseItemCommand(wx.Command):
def __init__(self, fitID, containerName, position, newTypeID):
wx.Command.__init__(self, True, "Rebase Item")
self.fitID = fitID
self.containerName = containerName
self.position = position
self.newTypeID = newTypeID
self.oldTypeID = None
def Do(self):
fit = eos.db.getFit(self.fitID)
obj = getattr(fit, self.containerName)[self.position]
self.oldTypeID = getattr(obj.item, "ID", None)
newItem = eos.db.getItem(self.newTypeID)
obj.rebase(newItem)
return True
def Undo(self):
cmd = FitRebaseItemCommand(self.fitID, self.containerName, self.position, self.oldTypeID)
return cmd.Do()

View File

@@ -8,7 +8,7 @@ from .calc.fitAddDrone import FitAddDroneCommand
class GuiAddDroneCommand(wx.Command):
def __init__(self, fitID, itemID):
wx.Command.__init__(self, True, "Cargo Add")
wx.Command.__init__(self, True, "Drone Add")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.internal_history = wx.CommandProcessor()

View File

@@ -8,7 +8,7 @@ from .calc.fitAddFighter import FitAddFighterCommand
class GuiAddFighterCommand(wx.Command):
def __init__(self, fitID, itemID):
wx.Command.__init__(self, True, "Cargo Add")
wx.Command.__init__(self, True, "Fighter Add")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.internal_history = wx.CommandProcessor()

View File

@@ -21,7 +21,7 @@ class GuiCargoToModuleCommand(wx.Command):
"""
def __init__(self, fitID, moduleIdx, cargoIdx, copy=False):
wx.Command.__init__(self, True, "Module State Change")
wx.Command.__init__(self, True, "Cargo to Module")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.fitID = fitID

View File

@@ -17,7 +17,7 @@ class GuiFillWithModuleCommand(wx.Command):
set the charge on the underlying module (requires position)
:param position: Optional. The position in fit.modules that we are attempting to set the item to
"""
wx.Command.__init__(self, True, "Module Add: {}".format(itemID))
wx.Command.__init__(self, True, "Module Fill: {}".format(itemID))
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.fitID = fitID

View File

@@ -14,7 +14,7 @@ pyfalog = Logger(__name__)
class GuiModuleToCargoCommand(wx.Command):
def __init__(self, fitID, moduleIdx, cargoIdx, copy=False):
wx.Command.__init__(self, True, "Module State Change")
wx.Command.__init__(self, True, "Module to Cargo")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.fitID = fitID

View File

@@ -0,0 +1,46 @@
import wx
import eos.db
import gui.mainFrame
from gui import globalEvents as GE
from service.fit import Fit
from .calc.fitRebaseItem import FitRebaseItemCommand
from .calc.fitSetCharge import FitSetChargeCommand
class GuiRebaseItemsCommand(wx.Command):
def __init__(self, fitID, rebaseMap):
wx.Command.__init__(self, True, "Mass Rebase Item")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.fitID = fitID
self.rebaseMap = rebaseMap
self.internal_history = wx.CommandProcessor()
def Do(self):
fit = eos.db.getFit(self.fitID)
for mod in fit.modules:
if mod.item is not None and mod.item.ID in self.rebaseMap:
self.internal_history.Submit(FitRebaseItemCommand(self.fitID, "modules", mod.modPosition, self.rebaseMap[mod.item.ID]))
if mod.charge is not None and mod.charge.ID in self.rebaseMap:
self.internal_history.Submit(FitSetChargeCommand(self.fitID, [mod.modPosition], self.rebaseMap[mod.charge.ID]))
for containerName in ("drones", "fighters", "implants", "boosters", "cargo"):
container = getattr(fit, containerName)
for obj in container:
if obj.item is not None and obj.item.ID in self.rebaseMap:
self.internal_history.Submit(FitRebaseItemCommand(self.fitID, containerName, container.index(obj), self.rebaseMap[obj.item.ID]))
if self.internal_history.Commands:
eos.db.commit()
Fit.getInstance().recalc(self.fitID)
wx.PostEvent(self.mainFrame, GE.FitChanged(fitID=self.fitID))
return True
else:
return False
def Undo(self):
for _ in self.internal_history.Commands:
self.internal_history.Undo()
eos.db.commit()
Fit.getInstance().recalc(self.fitID)
wx.PostEvent(self.mainFrame, GE.FitChanged(fitID=self.fitID))
return True

View File

@@ -8,7 +8,7 @@ from .calc.fitRemoveCargo import FitRemoveCargoCommand
class GuiRemoveCargoCommand(wx.Command):
def __init__(self, fitID, itemID):
wx.Command.__init__(self, True, "Module Charge Add")
wx.Command.__init__(self, True, "Cargo Remove")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.internal_history = wx.CommandProcessor()

View File

@@ -8,7 +8,7 @@ from .calc.fitRemoveDrone import FitRemoveDroneCommand
class GuiRemoveDroneCommand(wx.Command):
def __init__(self, fitID, position, amount=1):
wx.Command.__init__(self, True, "Cargo Add")
wx.Command.__init__(self, True, "Drone Remove")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.internal_history = wx.CommandProcessor()

View File

@@ -8,7 +8,7 @@ from .calc.fitRemoveFighter import FitRemoveFighterCommand
class GuiRemoveFighterCommand(wx.Command):
def __init__(self, fitID, position):
wx.Command.__init__(self, True, "Module Remove")
wx.Command.__init__(self, True, "Fighter Remove")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.fitID = fitID

View File

@@ -27,7 +27,7 @@ class GuiRemoveProjectedCommand(wx.Command):
}
def __init__(self, fitID, thing):
wx.Command.__init__(self, True, "Projected Add")
wx.Command.__init__(self, True, "Projected Remove")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.internal_history = wx.CommandProcessor()

View File

@@ -8,7 +8,7 @@ from .calc.fitSetMode import FitSetModeCommand
class GuiSetModeCommand(wx.Command):
def __init__(self, fitID, mode):
wx.Command.__init__(self, True, "Cargo Add")
wx.Command.__init__(self, True, "Mode Set")
self.mainFrame = gui.mainFrame.MainFrame.getInstance()
self.sFit = Fit.getInstance()
self.internal_history = wx.CommandProcessor()

View File

@@ -37,7 +37,6 @@ import config
import gui.globalEvents as GE
from eos.config import gamedata_date, gamedata_version
from eos.db.saveddata.loadDefaultDatabaseValues import DefaultDatabaseValues
from eos.db.saveddata.queries import getFit as db_getFit
# import this to access override setting
from eos.modifiedAttributeDict import ModifiedAttributeDict
from gui import graphFrame
@@ -64,11 +63,12 @@ from gui.setEditor import ImplantSetEditorDlg
from gui.shipBrowser import ShipBrowser
from gui.statsPane import StatsPane
from gui.updateDialog import UpdateDialog
from gui.utils.clipboard import fromClipboard, toClipboard
from gui.utils.clipboard import fromClipboard
from service.character import Character
from service.esi import Esi
from service.fit import Fit
from service.port import EfsPort, IPortUser, Port
from service.port import IPortUser, Port
from service.price import Price
from service.settings import HTMLExportSettings, SettingsProvider
from service.update import Update
import gui.fitCommands as cmd
@@ -508,6 +508,8 @@ class MainFrame(wx.Frame):
self.Bind(wx.EVT_MENU, self.saveCharAs, id=menuBar.saveCharAsId)
# Save current character
self.Bind(wx.EVT_MENU, self.revertChar, id=menuBar.revertCharId)
# Optimize fit price
self.Bind(wx.EVT_MENU, self.optimizeFitPrice, id=menuBar.optimizeFitPrice)
# Browse fittings
self.Bind(wx.EVT_MENU, self.eveFittings, id=menuBar.eveFittingsId)
@@ -655,6 +657,23 @@ class MainFrame(wx.Frame):
sChr.revertCharacter(charID)
wx.PostEvent(self, GE.CharListUpdated())
def optimizeFitPrice(self, event):
fitID = self.getActiveFit()
sFit = Fit.getInstance()
fit = sFit.getFit(fitID)
if fit:
def updateFitCb(replacementsCheaper):
del self.waitDialog
del self.disablerAll
rebaseMap = {k.ID: v.ID for k, v in replacementsCheaper.items()}
self.command.Submit(cmd.GuiRebaseItemsCommand(fitID, rebaseMap))
fitItems = {i for i in Fit.fitItemIter(fit) if i is not fit.ship.item}
self.disablerAll = wx.WindowDisabler()
self.waitDialog = wx.BusyInfo("Please Wait...", parent=self)
Price.getInstance().findCheaperReplacements(fitItems, updateFitCb, fetchTimeout=10)
def AdditionsTabSelect(self, event):
selTab = self.additionsSelect.index(event.GetId())
@@ -688,30 +707,6 @@ class MainFrame(wx.Frame):
else:
self.marketBrowser.search.Focus()
def clipboardEft(self, options):
fit = db_getFit(self.getActiveFit())
toClipboard(Port.exportEft(fit, options))
def clipboardDna(self, options):
fit = db_getFit(self.getActiveFit())
toClipboard(Port.exportDna(fit))
def clipboardEsi(self, options):
fit = db_getFit(self.getActiveFit())
toClipboard(Port.exportESI(fit))
def clipboardXml(self, options):
fit = db_getFit(self.getActiveFit())
toClipboard(Port.exportXml(None, fit))
def clipboardMultiBuy(self, options):
fit = db_getFit(self.getActiveFit())
toClipboard(Port.exportMultiBuy(fit, options))
def clipboardEfs(self, options):
fit = db_getFit(self.getActiveFit())
toClipboard(EfsPort.exportEfs(fit, 0))
def importFromClipboard(self, event):
clipboard = fromClipboard()
activeFit = self.getActiveFit()
@@ -728,28 +723,8 @@ class MainFrame(wx.Frame):
self._openAfterImport(importData)
def exportToClipboard(self, event):
CopySelectDict = {CopySelectDialog.copyFormatEft: self.clipboardEft,
CopySelectDialog.copyFormatXml: self.clipboardXml,
CopySelectDialog.copyFormatDna: self.clipboardDna,
CopySelectDialog.copyFormatEsi: self.clipboardEsi,
CopySelectDialog.copyFormatMultiBuy: self.clipboardMultiBuy,
CopySelectDialog.copyFormatEfs: self.clipboardEfs}
dlg = CopySelectDialog(self)
btnPressed = dlg.ShowModal()
if btnPressed == wx.ID_OK:
selected = dlg.GetSelected()
options = dlg.GetOptions()
settings = SettingsProvider.getInstance().getSettings("pyfaExport")
settings["format"] = selected
settings["options"] = options
CopySelectDict[selected](options.get(selected))
try:
dlg.Destroy()
except RuntimeError:
pyfalog.error("Tried to destroy an object that doesn't exist in <exportToClipboard>.")
with CopySelectDialog(self) as dlg:
dlg.ShowModal()
def exportSkillsNeeded(self, event):
""" Exports skills needed for active fit and active character """

View File

@@ -28,8 +28,6 @@ import gui.globalEvents as GE
from gui.bitmap_loader import BitmapLoader
from logbook import Logger
# from service.crest import Crest
# from service.crest import CrestModes
pyfalog = Logger(__name__)
@@ -59,6 +57,7 @@ class MainMenuBar(wx.MenuBar):
self.importDatabaseDefaultsId = wx.NewId()
self.toggleIgnoreRestrictionID = wx.NewId()
self.devToolsId = wx.NewId()
self.optimizeFitPrice = wx.NewId()
# pheonix: evaluate if this is needed
if 'wxMac' in wx.PlatformInfo and wx.VERSION >= (3, 0):
@@ -101,6 +100,9 @@ class MainMenuBar(wx.MenuBar):
editMenu.Append(self.revertCharId, "Revert Character")
editMenu.AppendSeparator()
self.ignoreRestrictionItem = editMenu.Append(self.toggleIgnoreRestrictionID, "Ignore Fitting Restrictions")
editMenu.AppendSeparator()
editMenu.Append(self.optimizeFitPrice, "Optimize Fit Price")
# Character menu
windowMenu = wx.Menu()
@@ -134,8 +136,6 @@ class MainMenuBar(wx.MenuBar):
preferencesItem.SetBitmap(BitmapLoader.getBitmap("preferences_small", "gui"))
windowMenu.Append(preferencesItem)
# self.sEsi = Crest.getInstance()
# CREST Menu
esiMMenu = wx.Menu()
self.Append(esiMMenu, "EVE &SSO")

View File

@@ -178,32 +178,17 @@ def main(db, json_path):
def fillReplacements(tables):
def compareAttrs(attrs1, attrs2, attrHig):
"""
Compares received attribute sets. Returns:
- 0 if sets are different
- 1 if sets are exactly the same
- 2 if first set is strictly better
- 3 if second set is strictly better
"""
def compareAttrs(attrs1, attrs2):
# Consider items as different if they have no attrs
if len(attrs1) == 0 and len(attrs2) == 0:
return False
if set(attrs1) != set(attrs2):
return 0
return False
if all(attrs1[aid] == attrs2[aid] for aid in attrs1):
return 1
if all(
(attrs1[aid] >= attrs2[aid] and attrHig[aid]) or
(attrs1[aid] <= attrs2[aid] and not attrHig[aid])
for aid in attrs1
):
return 2
if all(
(attrs2[aid] >= attrs1[aid] and attrHig[aid]) or
(attrs2[aid] <= attrs1[aid] and not attrHig[aid])
for aid in attrs1
):
return 3
return 0
return True
return False
print('finding replacements')
skillReqAttribs = {
182: 277,
183: 278,
@@ -213,10 +198,17 @@ def main(db, json_path):
1290: 1288}
skillReqAttribsFlat = set(skillReqAttribs.keys()).union(skillReqAttribs.values())
# Get data on type groups
# Format: {type ID: group ID}
typesGroups = {}
for row in tables['evetypes']:
typesGroups[row['typeID']] = row['groupID']
# Get data on item effects
# Format: {type ID: set(effect, IDs)}
typesEffects = {}
for row in tables['dgmtypeeffects']:
typesEffects.setdefault(row['typeID'], set()).add(row['effectID'])
# Get data on type attributes
# Format: {type ID: {attribute ID: attribute value}}
typesNormalAttribs = {}
typesSkillAttribs = {}
for row in tables['dgmtypeattribs']:
@@ -226,15 +218,23 @@ def main(db, json_path):
typeSkillAttribs[row['attributeID']] = row['value']
# Ignore these attributes for comparison purposes
elif attributeID in (
# We do not need mass as it affects final ship stats only when carried by ship itself
# (and we're not going to replace ships), but it's wildly inconsistent for other items,
# which otherwise would be the same
4, # mass
124, # mainColor
162, # radius
422, # techLevel
633, # metaLevel
1692 # metaGroupID
1692, # metaGroupID
1768 # typeColorScheme
):
continue
else:
typeNormalAttribs = typesNormalAttribs.setdefault(row['typeID'], {})
typeNormalAttribs[row['attributeID']] = row['value']
# Get data on skill requirements
# Format: {type ID: {skill type ID: skill level}}
typesSkillReqs = {}
for typeID, typeAttribs in typesSkillAttribs.items():
typeSkillAttribs = typesSkillAttribs.get(typeID, {})
@@ -248,46 +248,54 @@ def main(db, json_path):
except (KeyError, ValueError):
continue
typeSkillReqs[skillType] = skillLevel
# Get data on attribute highIsGood flag
attrHig = {}
for row in tables['dgmattribs']:
attrHig[row['attributeID']] = bool(row['highIsGood'])
# Format: {group ID: category ID}
groupCategories = {}
for row in tables['evegroups']:
groupCategories[row['groupID']] = row['categoryID']
# As EVE affects various types mostly depending on their group or skill requirements,
# we're going to group various types up this way
# Format: {(group ID, frozenset(skillreq, type, IDs), frozenset(type, effect, IDs): [type ID, {attribute ID: attribute value}]}
groupedData = {}
for row in tables['evetypes']:
typeID = row['typeID']
# Ignore items outside of categories we need
if groupCategories[typesGroups[typeID]] not in (
6, # Ship
7, # Module
8, # Charge
18, # Drone
20, # Implant
22, # Deployable
23, # Starbase
32, # Subsystem
35, # Decryptors
65, # Structure
66, # Structure Module
87, # Fighter
):
continue
typeAttribs = typesNormalAttribs.get(typeID, {})
# Ignore stuff w/o attributes
# Ignore items w/o attributes
if not typeAttribs:
continue
# We need only skill types, not levels for keys
typeSkillreqs = frozenset(typesSkillReqs.get(typeID, {}))
typeGroup = typesGroups[typeID]
groupData = groupedData.setdefault((typeGroup, typeSkillreqs), [])
typeEffects = frozenset(typesEffects.get(typeID, ()))
groupData = groupedData.setdefault((typeGroup, typeSkillreqs, typeEffects), [])
groupData.append((typeID, typeAttribs))
same = {}
better = {}
# Now, go through composed groups and for every item within it find items which are
# the same and which are better
# Format: {type ID: set(type IDs)}
replacements = {}
# Now, go through composed groups and for every item within it
# find items which are the same
for groupData in groupedData.values():
for type1, type2 in itertools.combinations(groupData, 2):
comparisonResult = compareAttrs(type1[1], type2[1], attrHig)
# Equal
if comparisonResult == 1:
same.setdefault(type1[0], set()).add(type2[0])
same.setdefault(type2[0], set()).add(type1[0])
# First is better
elif comparisonResult == 2:
better.setdefault(type2[0], set()).add(type1[0])
# Second is better
elif comparisonResult == 3:
better.setdefault(type1[0], set()).add(type2[0])
if compareAttrs(type1[1], type2[1]):
replacements.setdefault(type1[0], set()).add(type2[0])
replacements.setdefault(type2[0], set()).add(type1[0])
# Put this data into types table so that normal process hooks it up
for row in tables['evetypes']:
typeID = row['typeID']
row['replaceSame'] = ','.join('{}'.format(tid) for tid in sorted(same.get(typeID, ())))
row['replaceBetter'] = ','.join('{}'.format(tid) for tid in sorted(better.get(typeID, ())))
row['replacements'] = ','.join('{}'.format(tid) for tid in sorted(replacements.get(row['typeID'], ())))
data = {}

View File

@@ -56,6 +56,7 @@ class PortMultiBuyOptions(IntEnum):
IMPLANTS = 1
CARGO = 2
LOADED_CHARGES = 3
OPTIMIZE_PRICES = 4
@unique
@@ -99,4 +100,4 @@ class GuiAttrGroup(IntEnum):
ON_DEATH = auto()
JUMP_SYSTEMS = auto()
PROPULSIONS = auto()
FIGHTERS = auto()
FIGHTERS = auto()

View File

@@ -555,9 +555,26 @@ class Fit(FitDeprecated):
changed = True
return changed
# If any state was changed, recalculate attributes again
# if changed:
# self.recalc(fit)
@classmethod
def fitObjectIter(cls, fit):
yield fit.ship
for mod in fit.modules:
if not mod.isEmpty:
yield mod
for container in (fit.drones, fit.fighters, fit.implants, fit.boosters, fit.cargo):
for obj in container:
yield obj
@classmethod
def fitItemIter(cls, fit):
for fitobj in cls.fitObjectIter(fit):
yield fitobj.item
charge = getattr(fitobj, 'charge', None)
if charge:
yield charge
def refreshFit(self, fitID):
pyfalog.debug("Refresh fit for fit ID: {0}", fitID)

View File

@@ -795,3 +795,20 @@ class Market(object):
"""Filter items by meta lvl"""
filtered = set([item for item in items if self.getMetaGroupIdByItem(item) in metas])
return filtered
def getReplacements(self, identity):
item = self.getItem(identity)
# We already store needed type IDs in database
replTypeIDs = {int(i) for i in item.replacements.split(",") if i}
if not replTypeIDs:
return ()
# As replacements were generated without keeping track which items were published,
# filter them out here
items = []
for typeID in replTypeIDs:
item = self.getItem(typeID)
if not item:
continue
if self.getPublicityByItem(item):
items.append(item)
return items

View File

@@ -17,30 +17,37 @@
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
# =============================================================================
import time
from xml.dom import minidom
from logbook import Logger
from eos.saveddata.price import PriceStatus
from service.network import Network
from service.price import Price, TIMEOUT, VALIDITY
from service.price import Price
pyfalog = Logger(__name__)
class EveMarketData(object):
class EveMarketData:
name = "eve-marketdata.com"
def __init__(self, types, system, priceMap):
data = {}
baseurl = "https://eve-marketdata.com/api/item_prices.xml"
data["system_id"] = system # Use Jita for market
data["type_ids"] = ','.join(str(x) for x in types)
def __init__(self, priceMap, system, fetchTimeout):
# Try selected system first
self.fetchPrices(priceMap, max(2 * fetchTimeout / 3, 2), system)
# If price was not available - try globally
if priceMap:
self.fetchPrices(priceMap, max(fetchTimeout / 3, 2))
@staticmethod
def fetchPrices(priceMap, fetchTimeout, system=None):
params = {"type_ids": ','.join(str(typeID) for typeID in priceMap)}
if system is not None:
params["system_id"] = system
baseurl = "https://eve-marketdata.com/api/item_prices.xml"
network = Network.getInstance()
data = network.request(baseurl, network.PRICES, params=data)
data = network.request(baseurl, network.PRICES, params=params, timeout=fetchTimeout)
xml = minidom.parseString(data.text)
types = xml.getElementsByTagName("eve").item(0).getElementsByTagName("price")
@@ -55,19 +62,10 @@ class EveMarketData(object):
pyfalog.warning("Failed to get price for: {0}", type_)
continue
# Fill price data
priceobj = priceMap[typeID]
# eve-marketdata returns 0 if price data doesn't even exist for the item. In this case, don't reset the
# cached price, and set the price timeout to TIMEOUT (every 15 minutes currently). Se GH issue #1334
if price != 0:
priceobj.price = price
priceobj.time = time.time() + VALIDITY
priceobj.status = PriceStatus.success
else:
priceobj.time = time.time() + TIMEOUT
# delete price from working dict
# eve-marketdata returns 0 if price data doesn't even exist for the item
if price == 0:
continue
priceMap[typeID].update(PriceStatus.fetchSuccess, price)
del priceMap[typeID]

View File

@@ -17,33 +17,37 @@
# along with pyfa. If not, see <http://www.gnu.org/licenses/>.
# =============================================================================
import time
from xml.dom import minidom
from logbook import Logger
from eos.saveddata.price import PriceStatus
from service.network import Network
from service.price import Price, VALIDITY
from service.price import Price
pyfalog = Logger(__name__)
class EveMarketer(object):
class EveMarketer:
name = "evemarketer"
def __init__(self, types, system, priceMap):
data = {}
def __init__(self, priceMap, system, fetchTimeout):
# Try selected system first
self.fetchPrices(priceMap, max(2 * fetchTimeout / 3, 2), system)
# If price was not available - try globally
if priceMap:
self.fetchPrices(priceMap, max(fetchTimeout / 3, 2))
@staticmethod
def fetchPrices(priceMap, fetchTimeout, system=None):
params = {"typeid": {typeID for typeID in priceMap}}
if system is not None:
params["usesystem"] = system
baseurl = "https://api.evemarketer.com/ec/marketstat"
data["usesystem"] = system # Use Jita for market
data["typeid"] = set()
for typeID in types: # Add all typeID arguments
data["typeid"].add(typeID)
network = Network.getInstance()
data = network.request(baseurl, network.PRICES, params=data)
data = network.request(baseurl, network.PRICES, params=params, timeout=fetchTimeout)
xml = minidom.parseString(data.text)
types = xml.getElementsByTagName("marketstat").item(0).getElementsByTagName("type")
# Cycle through all types we've got from request
@@ -56,15 +60,15 @@ class EveMarketer(object):
percprice = float(sell.getElementsByTagName("percentile").item(0).firstChild.data)
except (TypeError, ValueError):
pyfalog.warning("Failed to get price for: {0}", type_)
percprice = 0
continue
# Fill price data
priceobj = priceMap[typeID]
priceobj.price = percprice
priceobj.time = time.time() + VALIDITY
priceobj.status = PriceStatus.success
# Price is 0 if evemarketer has info on this item, but it is not available
# for current scope limit. If we provided scope limit - make sure to skip
# such items to check globally, and do not skip if requested globally
if percprice == 0 and system is not None:
continue
# delete price from working dict
priceMap[typeID].update(PriceStatus.fetchSuccess, percprice)
del priceMap[typeID]

View File

@@ -53,7 +53,7 @@ class TimeoutError(Exception):
pass
class Network(object):
class Network:
# Request constants - every request must supply this, as it is checked if
# enabled or not via settings
ENABLED = 1

View File

@@ -124,7 +124,7 @@ def importDna(string):
return f
def exportDna(fit):
def exportDna(fit, callback):
dna = str(fit.shipID)
subsystems = [] # EVE cares which order you put these in
mods = OrderedDict()
@@ -174,4 +174,9 @@ def exportDna(fit):
for charge in charges:
dna += ":{0};{1}".format(charge, charges[charge])
return dna + "::"
text = dna + "::"
if callback:
callback(text)
else:
return text

View File

@@ -576,7 +576,7 @@ class EfsPort:
return sizeNotFoundMsg
@staticmethod
def exportEfs(fit, typeNotFitFlag):
def exportEfs(fit, typeNotFitFlag, callback):
sFit = Fit.getInstance()
includeShipTypeData = typeNotFitFlag > 0
if includeShipTypeData:
@@ -673,4 +673,8 @@ class EfsPort:
pyfalog.error(e)
dataDict = {"name": fitName + "Fit could not be correctly parsed"}
export = json.dumps(dataDict, skipkeys=True)
return export
if callback:
callback(export)
else:
return export

View File

@@ -19,7 +19,6 @@
import re
from service.const import PortEftOptions, PortEftRigSize
from logbook import Logger
@@ -34,6 +33,7 @@ from eos.saveddata.module import Module
from eos.saveddata.ship import Ship
from eos.saveddata.fit import Fit
from eos.const import FittingSlot, FittingModuleState
from service.const import PortEftOptions, PortEftRigSize
from service.fit import Fit as svcFit
from service.market import Market
from service.port.muta import parseMutant, renderMutant
@@ -54,7 +54,7 @@ SLOT_ORDER = (FittingSlot.LOW, FittingSlot.MED, FittingSlot.HIGH, FittingSlot.RI
OFFLINE_SUFFIX = '/OFFLINE'
def exportEft(fit, options):
def exportEft(fit, options, callback):
# EFT formatted export is split in several sections, each section is
# separated from another using 2 blank lines. Sections might have several
# sub-sections, which are separated by 1 blank line
@@ -150,7 +150,12 @@ def exportEft(fit, options):
if mutationLines:
sections.append('\n'.join(mutationLines))
return '{}\n\n{}'.format(header, '\n\n\n'.join(sections))
text = '{}\n\n{}'.format(header, '\n\n\n'.join(sections))
if callback:
callback(text)
else:
return text
def importEft(lines):

View File

@@ -55,7 +55,7 @@ INV_FLAG_DRONEBAY = 87
INV_FLAG_FIGHTER = 158
def exportESI(ofit):
def exportESI(ofit, callback):
# A few notes:
# max fit name length is 50 characters
# Most keys are created simply because they are required, but bogus data is okay
@@ -135,7 +135,12 @@ def exportESI(ofit):
if len(fit['items']) == 0:
raise ESIExportException("Cannot export fitting: module list cannot be empty.")
return json.dumps(fit)
text = json.dumps(fit)
if callback:
callback(text)
else:
return text
def importESI(string):

View File

@@ -19,52 +19,76 @@
from service.const import PortMultiBuyOptions
from service.price import Price as sPrc
MULTIBUY_OPTIONS = (
(PortMultiBuyOptions.LOADED_CHARGES.value, 'Loaded Charges', 'Export charges loaded into modules', True),
(PortMultiBuyOptions.IMPLANTS.value, 'Implants && Boosters', 'Export implants and boosters', False),
(PortMultiBuyOptions.CARGO.value, 'Cargo', 'Export cargo contents', True),
(PortMultiBuyOptions.OPTIMIZE_PRICES.value, 'Optimize Prices', 'Replace items by cheaper alternatives', False),
)
def exportMultiBuy(fit, options):
itemCounts = {}
def addItem(item, quantity=1):
if item not in itemCounts:
itemCounts[item] = 0
itemCounts[item] += quantity
def exportMultiBuy(fit, options, callback):
itemAmounts = {}
for module in fit.modules:
if module.item:
# Mutated items are of no use for multibuy
if module.isMutated:
continue
addItem(module.item)
_addItem(itemAmounts, module.item)
if module.charge and options[PortMultiBuyOptions.LOADED_CHARGES.value]:
addItem(module.charge, module.numCharges)
_addItem(itemAmounts, module.charge, module.numCharges)
for drone in fit.drones:
addItem(drone.item, drone.amount)
_addItem(itemAmounts, drone.item, drone.amount)
for fighter in fit.fighters:
addItem(fighter.item, fighter.amountActive)
_addItem(itemAmounts, fighter.item, fighter.amountActive)
if options[PortMultiBuyOptions.CARGO.value]:
for cargo in fit.cargo:
addItem(cargo.item, cargo.amount)
_addItem(itemAmounts, cargo.item, cargo.amount)
if options[PortMultiBuyOptions.IMPLANTS.value]:
for implant in fit.implants:
addItem(implant.item)
_addItem(itemAmounts, implant.item)
for booster in fit.boosters:
addItem(booster.item)
_addItem(itemAmounts, booster.item)
if options[PortMultiBuyOptions.OPTIMIZE_PRICES.value]:
def formatCheaperExportCb(replacementsCheaper):
updatedAmounts = {}
for item, itemAmount in itemAmounts.items():
_addItem(updatedAmounts, replacementsCheaper.get(item, item), itemAmount)
string = _prepareString(fit.ship.item, updatedAmounts)
callback(string)
priceSvc = sPrc.getInstance()
priceSvc.findCheaperReplacements(itemAmounts, formatCheaperExportCb)
else:
string = _prepareString(fit.ship.item, itemAmounts)
if callback:
callback(string)
else:
return string
def _addItem(container, item, quantity=1):
if item not in container:
container[item] = 0
container[item] += quantity
def _prepareString(shipItem, itemAmounts):
exportLines = []
exportLines.append(fit.ship.item.name)
for item in sorted(itemCounts, key=lambda i: (i.group.category.name, i.group.name, i.name)):
count = itemCounts[item]
exportLines.append(shipItem.name)
for item in sorted(itemAmounts, key=lambda i: (i.group.category.name, i.group.name, i.name)):
count = itemAmounts[item]
if count == 1:
exportLines.append(item.name)
else:

View File

@@ -257,8 +257,8 @@ class Port(object):
return importEftCfg(shipname, lines, iportuser)
@classmethod
def exportEft(cls, fit, options):
return exportEft(fit, options)
def exportEft(cls, fit, options, callback=None):
return exportEft(fit, options, callback=callback)
# DNA-related methods
@staticmethod
@@ -266,8 +266,8 @@ class Port(object):
return importDna(string)
@staticmethod
def exportDna(fit):
return exportDna(fit)
def exportDna(fit, callback=None):
return exportDna(fit, callback=callback)
# ESI-related methods
@staticmethod
@@ -275,8 +275,8 @@ class Port(object):
return importESI(string)
@staticmethod
def exportESI(fit):
return exportESI(fit)
def exportESI(fit, callback=None):
return exportESI(fit, callback=callback)
# XML-related methods
@staticmethod
@@ -284,10 +284,10 @@ class Port(object):
return importXml(text, iportuser)
@staticmethod
def exportXml(iportuser=None, *fits):
return exportXml(iportuser, *fits)
def exportXml(iportuser=None, callback=None, *fits):
return exportXml(iportuser, callback=callback, *fits)
# Multibuy-related methods
@staticmethod
def exportMultiBuy(fit, options):
return exportMultiBuy(fit, options)
def exportMultiBuy(fit, options, callback=None):
return exportMultiBuy(fit, options, callback=callback)

View File

@@ -226,14 +226,13 @@ def importXml(text, iportuser):
return fit_list
def exportXml(iportuser, *fits):
def exportXml(iportuser, callback, *fits):
doc = xml.dom.minidom.Document()
fittings = doc.createElement("fittings")
# fit count
fit_count = len(fits)
fittings.setAttribute("count", "%s" % fit_count)
doc.appendChild(fittings)
sFit = svcFit.getInstance()
for i, fit in enumerate(fits):
try:
@@ -324,4 +323,9 @@ def exportXml(iportuser, *fits):
iportuser, IPortUser.PROCESS_EXPORT | IPortUser.ID_UPDATE,
(i, "convert to xml (%s/%s) %s" % (i + 1, fit_count, fit.ship.name))
)
return doc.toprettyxml()
text = doc.toprettyxml()
if callback:
callback(text)
else:
return text

View File

@@ -18,28 +18,26 @@
# =============================================================================
import math
import queue
import threading
import time
from itertools import chain
import wx
from logbook import Logger
from eos import db
from eos.saveddata.price import PriceStatus
from gui.fitCommands.guiRebaseItems import GuiRebaseItemsCommand
from service.fit import Fit
from service.market import Market
from service.network import TimeoutError
pyfalog = Logger(__name__)
VALIDITY = 24 * 60 * 60 # Price validity period, 24 hours
REREQUEST = 4 * 60 * 60 # Re-request delay for failed fetches, 4 hours
TIMEOUT = 15 * 60 # Network timeout delay for connection issues, 15 minutes
class Price(object):
class Price:
instance = None
systemsList = {
@@ -69,38 +67,34 @@ class Price(object):
return cls.instance
@classmethod
def fetchPrices(cls, prices):
def fetchPrices(cls, prices, fetchTimeout, validityOverride):
"""Fetch all prices passed to this method"""
# Dictionary for our price objects
priceMap = {}
# Check all provided price objects, and add invalid ones to dictionary
# Check all provided price objects, and add those we want to update to
# dictionary
for price in prices:
if not price.isValid:
if not price.isValid(validityOverride):
priceMap[price.typeID] = price
if len(priceMap) == 0:
if not priceMap:
return
# Set of items which are still to be requested from this service
toRequest = set()
# Compose list of items we're going to request
for typeID in tuple(priceMap):
# Get item object
item = db.getItem(typeID)
# We're not going to request items only with market group, as eve-central
# doesn't provide any data for items not on the market
# We're not going to request items only with market group, as our current market
# sources do not provide any data for items not on the market
if item is None:
continue
if not item.marketGroupID:
priceMap[typeID].status = PriceStatus.notSupported
priceMap[typeID].update(PriceStatus.notSupported)
del priceMap[typeID]
continue
toRequest.add(typeID)
# Do not waste our time if all items are not on the market
if len(toRequest) == 0:
if not priceMap:
return
sFit = Fit.getInstance()
@@ -110,62 +104,46 @@ class Price(object):
return
# attempt to find user's selected price source, otherwise get first one
sourcesToTry = list(cls.sources.keys())
curr = sFit.serviceFittingOptions["priceSource"] if sFit.serviceFittingOptions["priceSource"] in sourcesToTry else sourcesToTry[0]
sourceAll = list(cls.sources.keys())
sourcePrimary = sFit.serviceFittingOptions["priceSource"] if sFit.serviceFittingOptions["priceSource"] in sourceAll else sourceAll[0]
while len(sourcesToTry) > 0:
sourcesToTry.remove(curr)
# Format: {source name: timeout weight}
sources = {sourcePrimary: len(sourceAll)}
for source in sourceAll:
if source == sourcePrimary:
continue
sources[source] = min(sources.values()) - 1
timeoutWeightMult = fetchTimeout / sum(sources.values())
# Record timeouts as it will affect our final decision
timedOutSources = {}
for source, timeoutWeight in sources.items():
pyfalog.info('Trying {}'.format(source))
timedOutSources[source] = False
sourceFetchTimeout = timeoutWeight * timeoutWeightMult
try:
sourceCls = cls.sources.get(curr)
sourceCls(toRequest, cls.systemsList[sFit.serviceFittingOptions["priceSystem"]], priceMap)
break
# If getting or processing data returned any errors
sourceCls = cls.sources.get(source)
sourceCls(priceMap, cls.systemsList[sFit.serviceFittingOptions["priceSystem"]], sourceFetchTimeout)
except TimeoutError:
# Timeout error deserves special treatment
pyfalog.warning("Price fetch timout")
for typeID in tuple(priceMap):
priceobj = priceMap[typeID]
priceobj.time = time.time() + TIMEOUT
priceobj.status = PriceStatus.fail
del priceMap[typeID]
except Exception as ex:
# something happened, try another source
pyfalog.warn('Failed to fetch prices from price source {}: {}'.format(curr, ex, sourcesToTry[0]))
if len(sourcesToTry) > 0:
pyfalog.warn('Trying {}'.format(sourcesToTry[0]))
curr = sourcesToTry[0]
pyfalog.warning("Price fetch timeout for source {}".format(source))
timedOutSources[source] = True
except Exception as e:
pyfalog.warn('Failed to fetch prices from price source {}: {}'.format(source, e))
# Sources remove price map items as they fetch info, if none remain then we're done
if not priceMap:
break
# if we get to this point, then we've got an error in all of our sources. Set to REREQUEST delay
for typeID in priceMap.keys():
priceobj = priceMap[typeID]
priceobj.time = time.time() + REREQUEST
priceobj.status = PriceStatus.fail
@classmethod
def fitItemsList(cls, fit):
# Compose a list of all the data we need & request it
fit_items = [fit.ship.item]
for mod in fit.modules:
if not mod.isEmpty:
fit_items.append(mod.item)
for drone in fit.drones:
fit_items.append(drone.item)
for fighter in fit.fighters:
fit_items.append(fighter.item)
for cargo in fit.cargo:
fit_items.append(cargo.item)
for boosters in fit.boosters:
fit_items.append(boosters.item)
for implants in fit.implants:
fit_items.append(implants.item)
return list(set(fit_items))
# If we get to this point, then we've failed to get price with all our sources
# If all sources failed due to timeouts, set one status
if all(to is True for to in timedOutSources.values()):
for typeID in priceMap.keys():
priceMap[typeID].update(PriceStatus.fetchTimeout)
# If some sources failed due to any other reason, then it's definitely not network
# timeout and we just set another status
else:
for typeID in priceMap.keys():
priceMap[typeID].update(PriceStatus.fetchFail)
def getPriceNow(self, objitem):
"""Get price for provided typeID"""
@@ -174,7 +152,7 @@ class Price(object):
return item.price.price
def getPrices(self, objitems, callback, waitforthread=False):
def getPrices(self, objitems, callback, fetchTimeout=30, waitforthread=False, validityOverride=None):
"""Get prices for multiple typeIDs"""
requests = []
sMkt = Market.getInstance()
@@ -186,7 +164,7 @@ class Price(object):
try:
callback(requests)
except Exception as e:
pyfalog.critical("Callback failed.")
pyfalog.critical("Execution of callback from getPrices failed.")
pyfalog.critical(e)
db.commit()
@@ -194,12 +172,43 @@ class Price(object):
if waitforthread:
self.priceWorkerThread.setToWait(requests, cb)
else:
self.priceWorkerThread.trigger(requests, cb)
self.priceWorkerThread.trigger(requests, cb, fetchTimeout, validityOverride)
def clearPriceCache(self):
pyfalog.debug("Clearing Prices")
db.clearPrices()
def findCheaperReplacements(self, items, callback, fetchTimeout=10):
sMkt = Market.getInstance()
replacementsAll = {} # All possible item replacements
for item in items:
if item in replacementsAll:
continue
itemRepls = sMkt.getReplacements(item)
if itemRepls:
replacementsAll[item] = itemRepls
itemsToFetch = {i for i in chain(replacementsAll.keys(), *replacementsAll.values())}
def makeCheapMapCb(requests):
# Decide what we are going to replace
replacementsCheaper = {} # Items which should be replaced
for replacee, replacers in replacementsAll.items():
replacer = min(replacers, key=lambda i: i.price.price or math.inf)
if (replacer.price.price or math.inf) < (replacee.price.price or math.inf):
replacementsCheaper[replacee] = replacer
try:
callback(replacementsCheaper)
except Exception as e:
pyfalog.critical("Execution of callback from findCheaperReplacements failed.")
pyfalog.critical(e)
# Prices older than 2 hours have to be refetched
validityOverride = 2 * 60 * 60
self.getPrices(itemsToFetch, makeCheapMapCb, fetchTimeout=fetchTimeout, validityOverride=validityOverride)
class PriceWorkerThread(threading.Thread):
@@ -214,11 +223,11 @@ class PriceWorkerThread(threading.Thread):
queue = self.queue
while True:
# Grab our data
callback, requests = queue.get()
callback, requests, fetchTimeout, validityOverride = queue.get()
# Grab prices, this is the time-consuming part
if len(requests) > 0:
Price.fetchPrices(requests)
Price.fetchPrices(requests, fetchTimeout, validityOverride)
wx.CallAfter(callback)
queue.task_done()
@@ -230,14 +239,13 @@ class PriceWorkerThread(threading.Thread):
for callback in callbacks:
wx.CallAfter(callback)
def trigger(self, prices, callbacks):
self.queue.put((callbacks, prices))
def trigger(self, prices, callbacks, fetchTimeout, validityOverride):
self.queue.put((callbacks, prices, fetchTimeout, validityOverride))
def setToWait(self, prices, callback):
for x in prices:
if x.typeID not in self.wait:
self.wait[x.typeID] = []
self.wait[x.typeID].append(callback)
for price in prices:
callbacks = self.wait.setdefault(price.typeID, [])
callbacks.append(callback)
# Import market sources only to initialize price source modules, they register on their own