tabsight 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (328) hide show
  1. tabsight/__init__.py +89 -0
  2. tabsight/__main__.py +3 -0
  3. tabsight/_version.py +1 -0
  4. tabsight/accessor.py +114 -0
  5. tabsight/aggregate.py +759 -0
  6. tabsight/analytics.py +275 -0
  7. tabsight/auto.py +249 -0
  8. tabsight/charts/__init__.py +737 -0
  9. tabsight/charts/_build.py +359 -0
  10. tabsight/charts/_common.py +124 -0
  11. tabsight/charts/extra.py +236 -0
  12. tabsight/charts/facets.py +369 -0
  13. tabsight/charts/grids.py +202 -0
  14. tabsight/charts/maps.py +669 -0
  15. tabsight/charts/pane.py +336 -0
  16. tabsight/charts/parts.py +319 -0
  17. tabsight/charts/relation.py +530 -0
  18. tabsight/check.py +318 -0
  19. tabsight/clean.py +409 -0
  20. tabsight/cli.py +295 -0
  21. tabsight/colors.py +155 -0
  22. tabsight/dashboard.py +1436 -0
  23. tabsight/figure.py +241 -0
  24. tabsight/formats.py +487 -0
  25. tabsight/formulas.py +754 -0
  26. tabsight/geo/__init__.py +109 -0
  27. tabsight/geo/data/__init__.py +1 -0
  28. tabsight/geo/data/admin1/ABW.topo.json.gz +0 -0
  29. tabsight/geo/data/admin1/AFG.topo.json.gz +0 -0
  30. tabsight/geo/data/admin1/AGO.topo.json.gz +0 -0
  31. tabsight/geo/data/admin1/AIA.topo.json.gz +0 -0
  32. tabsight/geo/data/admin1/ALB.topo.json.gz +0 -0
  33. tabsight/geo/data/admin1/ALD.topo.json.gz +0 -0
  34. tabsight/geo/data/admin1/AND.topo.json.gz +0 -0
  35. tabsight/geo/data/admin1/ARE.topo.json.gz +0 -0
  36. tabsight/geo/data/admin1/ARG.topo.json.gz +0 -0
  37. tabsight/geo/data/admin1/ARM.topo.json.gz +0 -0
  38. tabsight/geo/data/admin1/ASM.topo.json.gz +0 -0
  39. tabsight/geo/data/admin1/ATA.topo.json.gz +0 -0
  40. tabsight/geo/data/admin1/ATC.topo.json.gz +0 -0
  41. tabsight/geo/data/admin1/ATF.topo.json.gz +0 -0
  42. tabsight/geo/data/admin1/ATG.topo.json.gz +0 -0
  43. tabsight/geo/data/admin1/AUS.topo.json.gz +0 -0
  44. tabsight/geo/data/admin1/AUT.topo.json.gz +0 -0
  45. tabsight/geo/data/admin1/AZE.topo.json.gz +0 -0
  46. tabsight/geo/data/admin1/BDI.topo.json.gz +0 -0
  47. tabsight/geo/data/admin1/BEL.topo.json.gz +0 -0
  48. tabsight/geo/data/admin1/BEN.topo.json.gz +0 -0
  49. tabsight/geo/data/admin1/BFA.topo.json.gz +0 -0
  50. tabsight/geo/data/admin1/BGD.topo.json.gz +0 -0
  51. tabsight/geo/data/admin1/BGR.topo.json.gz +0 -0
  52. tabsight/geo/data/admin1/BHR.topo.json.gz +0 -0
  53. tabsight/geo/data/admin1/BHS.topo.json.gz +0 -0
  54. tabsight/geo/data/admin1/BIH.topo.json.gz +0 -0
  55. tabsight/geo/data/admin1/BLM.topo.json.gz +0 -0
  56. tabsight/geo/data/admin1/BLR.topo.json.gz +0 -0
  57. tabsight/geo/data/admin1/BLZ.topo.json.gz +0 -0
  58. tabsight/geo/data/admin1/BMU.topo.json.gz +0 -0
  59. tabsight/geo/data/admin1/BOL.topo.json.gz +0 -0
  60. tabsight/geo/data/admin1/BRA.topo.json.gz +0 -0
  61. tabsight/geo/data/admin1/BRB.topo.json.gz +0 -0
  62. tabsight/geo/data/admin1/BRN.topo.json.gz +0 -0
  63. tabsight/geo/data/admin1/BTN.topo.json.gz +0 -0
  64. tabsight/geo/data/admin1/BWA.topo.json.gz +0 -0
  65. tabsight/geo/data/admin1/CAF.topo.json.gz +0 -0
  66. tabsight/geo/data/admin1/CAN.topo.json.gz +0 -0
  67. tabsight/geo/data/admin1/CHE.topo.json.gz +0 -0
  68. tabsight/geo/data/admin1/CHL.topo.json.gz +0 -0
  69. tabsight/geo/data/admin1/CHN.topo.json.gz +0 -0
  70. tabsight/geo/data/admin1/CIV.topo.json.gz +0 -0
  71. tabsight/geo/data/admin1/CLP.topo.json.gz +0 -0
  72. tabsight/geo/data/admin1/CMR.topo.json.gz +0 -0
  73. tabsight/geo/data/admin1/COD.topo.json.gz +0 -0
  74. tabsight/geo/data/admin1/COG.topo.json.gz +0 -0
  75. tabsight/geo/data/admin1/COK.topo.json.gz +0 -0
  76. tabsight/geo/data/admin1/COL.topo.json.gz +0 -0
  77. tabsight/geo/data/admin1/COM.topo.json.gz +0 -0
  78. tabsight/geo/data/admin1/CPV.topo.json.gz +0 -0
  79. tabsight/geo/data/admin1/CRI.topo.json.gz +0 -0
  80. tabsight/geo/data/admin1/CSI.topo.json.gz +0 -0
  81. tabsight/geo/data/admin1/CUB.topo.json.gz +0 -0
  82. tabsight/geo/data/admin1/CUW.topo.json.gz +0 -0
  83. tabsight/geo/data/admin1/CYM.topo.json.gz +0 -0
  84. tabsight/geo/data/admin1/CYN.topo.json.gz +0 -0
  85. tabsight/geo/data/admin1/CYP.topo.json.gz +0 -0
  86. tabsight/geo/data/admin1/CZE.topo.json.gz +0 -0
  87. tabsight/geo/data/admin1/DEU.topo.json.gz +0 -0
  88. tabsight/geo/data/admin1/DJI.topo.json.gz +0 -0
  89. tabsight/geo/data/admin1/DMA.topo.json.gz +0 -0
  90. tabsight/geo/data/admin1/DNK.topo.json.gz +0 -0
  91. tabsight/geo/data/admin1/DOM.topo.json.gz +0 -0
  92. tabsight/geo/data/admin1/DZA.topo.json.gz +0 -0
  93. tabsight/geo/data/admin1/ECU.topo.json.gz +0 -0
  94. tabsight/geo/data/admin1/EGY.topo.json.gz +0 -0
  95. tabsight/geo/data/admin1/ERI.topo.json.gz +0 -0
  96. tabsight/geo/data/admin1/ESB.topo.json.gz +0 -0
  97. tabsight/geo/data/admin1/ESP.topo.json.gz +0 -0
  98. tabsight/geo/data/admin1/EST.topo.json.gz +0 -0
  99. tabsight/geo/data/admin1/ETH.topo.json.gz +0 -0
  100. tabsight/geo/data/admin1/FIN.topo.json.gz +0 -0
  101. tabsight/geo/data/admin1/FJI.topo.json.gz +0 -0
  102. tabsight/geo/data/admin1/FLK.topo.json.gz +0 -0
  103. tabsight/geo/data/admin1/FRA.topo.json.gz +0 -0
  104. tabsight/geo/data/admin1/FRO.topo.json.gz +0 -0
  105. tabsight/geo/data/admin1/FSM.topo.json.gz +0 -0
  106. tabsight/geo/data/admin1/GAB.topo.json.gz +0 -0
  107. tabsight/geo/data/admin1/GBR.topo.json.gz +0 -0
  108. tabsight/geo/data/admin1/GEO.topo.json.gz +0 -0
  109. tabsight/geo/data/admin1/GGY.topo.json.gz +0 -0
  110. tabsight/geo/data/admin1/GHA.topo.json.gz +0 -0
  111. tabsight/geo/data/admin1/GIB.topo.json.gz +0 -0
  112. tabsight/geo/data/admin1/GIN.topo.json.gz +0 -0
  113. tabsight/geo/data/admin1/GMB.topo.json.gz +0 -0
  114. tabsight/geo/data/admin1/GNB.topo.json.gz +0 -0
  115. tabsight/geo/data/admin1/GNQ.topo.json.gz +0 -0
  116. tabsight/geo/data/admin1/GRC.topo.json.gz +0 -0
  117. tabsight/geo/data/admin1/GRD.topo.json.gz +0 -0
  118. tabsight/geo/data/admin1/GRL.topo.json.gz +0 -0
  119. tabsight/geo/data/admin1/GTM.topo.json.gz +0 -0
  120. tabsight/geo/data/admin1/GUM.topo.json.gz +0 -0
  121. tabsight/geo/data/admin1/GUY.topo.json.gz +0 -0
  122. tabsight/geo/data/admin1/HKG.topo.json.gz +0 -0
  123. tabsight/geo/data/admin1/HMD.topo.json.gz +0 -0
  124. tabsight/geo/data/admin1/HND.topo.json.gz +0 -0
  125. tabsight/geo/data/admin1/HRV.topo.json.gz +0 -0
  126. tabsight/geo/data/admin1/HTI.topo.json.gz +0 -0
  127. tabsight/geo/data/admin1/HUN.topo.json.gz +0 -0
  128. tabsight/geo/data/admin1/IDN.topo.json.gz +0 -0
  129. tabsight/geo/data/admin1/IMN.topo.json.gz +0 -0
  130. tabsight/geo/data/admin1/IND.topo.json.gz +0 -0
  131. tabsight/geo/data/admin1/IOA.topo.json.gz +0 -0
  132. tabsight/geo/data/admin1/IOT.topo.json.gz +0 -0
  133. tabsight/geo/data/admin1/IRL.topo.json.gz +0 -0
  134. tabsight/geo/data/admin1/IRN.topo.json.gz +0 -0
  135. tabsight/geo/data/admin1/IRQ.topo.json.gz +0 -0
  136. tabsight/geo/data/admin1/ISL.topo.json.gz +0 -0
  137. tabsight/geo/data/admin1/ISR.topo.json.gz +0 -0
  138. tabsight/geo/data/admin1/ITA.topo.json.gz +0 -0
  139. tabsight/geo/data/admin1/JAM.topo.json.gz +0 -0
  140. tabsight/geo/data/admin1/JEY.topo.json.gz +0 -0
  141. tabsight/geo/data/admin1/JOR.topo.json.gz +0 -0
  142. tabsight/geo/data/admin1/JPN.topo.json.gz +0 -0
  143. tabsight/geo/data/admin1/KAB.topo.json.gz +0 -0
  144. tabsight/geo/data/admin1/KAS.topo.json.gz +0 -0
  145. tabsight/geo/data/admin1/KAZ.topo.json.gz +0 -0
  146. tabsight/geo/data/admin1/KEN.topo.json.gz +0 -0
  147. tabsight/geo/data/admin1/KGZ.topo.json.gz +0 -0
  148. tabsight/geo/data/admin1/KHM.topo.json.gz +0 -0
  149. tabsight/geo/data/admin1/KIR.topo.json.gz +0 -0
  150. tabsight/geo/data/admin1/KNA.topo.json.gz +0 -0
  151. tabsight/geo/data/admin1/KOR.topo.json.gz +0 -0
  152. tabsight/geo/data/admin1/KOS.topo.json.gz +0 -0
  153. tabsight/geo/data/admin1/KWT.topo.json.gz +0 -0
  154. tabsight/geo/data/admin1/LAO.topo.json.gz +0 -0
  155. tabsight/geo/data/admin1/LBN.topo.json.gz +0 -0
  156. tabsight/geo/data/admin1/LBR.topo.json.gz +0 -0
  157. tabsight/geo/data/admin1/LBY.topo.json.gz +0 -0
  158. tabsight/geo/data/admin1/LCA.topo.json.gz +0 -0
  159. tabsight/geo/data/admin1/LIE.topo.json.gz +0 -0
  160. tabsight/geo/data/admin1/LKA.topo.json.gz +0 -0
  161. tabsight/geo/data/admin1/LSO.topo.json.gz +0 -0
  162. tabsight/geo/data/admin1/LTU.topo.json.gz +0 -0
  163. tabsight/geo/data/admin1/LUX.topo.json.gz +0 -0
  164. tabsight/geo/data/admin1/LVA.topo.json.gz +0 -0
  165. tabsight/geo/data/admin1/MAC.topo.json.gz +0 -0
  166. tabsight/geo/data/admin1/MAF.topo.json.gz +0 -0
  167. tabsight/geo/data/admin1/MAR.topo.json.gz +0 -0
  168. tabsight/geo/data/admin1/MCO.topo.json.gz +0 -0
  169. tabsight/geo/data/admin1/MDA.topo.json.gz +0 -0
  170. tabsight/geo/data/admin1/MDG.topo.json.gz +0 -0
  171. tabsight/geo/data/admin1/MDV.topo.json.gz +0 -0
  172. tabsight/geo/data/admin1/MEX.topo.json.gz +0 -0
  173. tabsight/geo/data/admin1/MHL.topo.json.gz +0 -0
  174. tabsight/geo/data/admin1/MKD.topo.json.gz +0 -0
  175. tabsight/geo/data/admin1/MLI.topo.json.gz +0 -0
  176. tabsight/geo/data/admin1/MLT.topo.json.gz +0 -0
  177. tabsight/geo/data/admin1/MMR.topo.json.gz +0 -0
  178. tabsight/geo/data/admin1/MNE.topo.json.gz +0 -0
  179. tabsight/geo/data/admin1/MNG.topo.json.gz +0 -0
  180. tabsight/geo/data/admin1/MNP.topo.json.gz +0 -0
  181. tabsight/geo/data/admin1/MOZ.topo.json.gz +0 -0
  182. tabsight/geo/data/admin1/MRT.topo.json.gz +0 -0
  183. tabsight/geo/data/admin1/MSR.topo.json.gz +0 -0
  184. tabsight/geo/data/admin1/MUS.topo.json.gz +0 -0
  185. tabsight/geo/data/admin1/MWI.topo.json.gz +0 -0
  186. tabsight/geo/data/admin1/MYS.topo.json.gz +0 -0
  187. tabsight/geo/data/admin1/NAM.topo.json.gz +0 -0
  188. tabsight/geo/data/admin1/NCL.topo.json.gz +0 -0
  189. tabsight/geo/data/admin1/NER.topo.json.gz +0 -0
  190. tabsight/geo/data/admin1/NFK.topo.json.gz +0 -0
  191. tabsight/geo/data/admin1/NGA.topo.json.gz +0 -0
  192. tabsight/geo/data/admin1/NIC.topo.json.gz +0 -0
  193. tabsight/geo/data/admin1/NIU.topo.json.gz +0 -0
  194. tabsight/geo/data/admin1/NLD.topo.json.gz +0 -0
  195. tabsight/geo/data/admin1/NOR.topo.json.gz +0 -0
  196. tabsight/geo/data/admin1/NPL.topo.json.gz +0 -0
  197. tabsight/geo/data/admin1/NRU.topo.json.gz +0 -0
  198. tabsight/geo/data/admin1/NZL.topo.json.gz +0 -0
  199. tabsight/geo/data/admin1/OMN.topo.json.gz +0 -0
  200. tabsight/geo/data/admin1/PAK.topo.json.gz +0 -0
  201. tabsight/geo/data/admin1/PAN.topo.json.gz +0 -0
  202. tabsight/geo/data/admin1/PCN.topo.json.gz +0 -0
  203. tabsight/geo/data/admin1/PER.topo.json.gz +0 -0
  204. tabsight/geo/data/admin1/PGA.topo.json.gz +0 -0
  205. tabsight/geo/data/admin1/PHL.topo.json.gz +0 -0
  206. tabsight/geo/data/admin1/PLW.topo.json.gz +0 -0
  207. tabsight/geo/data/admin1/PNG.topo.json.gz +0 -0
  208. tabsight/geo/data/admin1/POL.topo.json.gz +0 -0
  209. tabsight/geo/data/admin1/PRI.topo.json.gz +0 -0
  210. tabsight/geo/data/admin1/PRK.topo.json.gz +0 -0
  211. tabsight/geo/data/admin1/PRT.topo.json.gz +0 -0
  212. tabsight/geo/data/admin1/PRY.topo.json.gz +0 -0
  213. tabsight/geo/data/admin1/PSX.topo.json.gz +0 -0
  214. tabsight/geo/data/admin1/PYF.topo.json.gz +0 -0
  215. tabsight/geo/data/admin1/QAT.topo.json.gz +0 -0
  216. tabsight/geo/data/admin1/ROU.topo.json.gz +0 -0
  217. tabsight/geo/data/admin1/RUS.topo.json.gz +0 -0
  218. tabsight/geo/data/admin1/RWA.topo.json.gz +0 -0
  219. tabsight/geo/data/admin1/SAH.topo.json.gz +0 -0
  220. tabsight/geo/data/admin1/SAU.topo.json.gz +0 -0
  221. tabsight/geo/data/admin1/SDN.topo.json.gz +0 -0
  222. tabsight/geo/data/admin1/SDS.topo.json.gz +0 -0
  223. tabsight/geo/data/admin1/SEN.topo.json.gz +0 -0
  224. tabsight/geo/data/admin1/SGP.topo.json.gz +0 -0
  225. tabsight/geo/data/admin1/SGS.topo.json.gz +0 -0
  226. tabsight/geo/data/admin1/SHN.topo.json.gz +0 -0
  227. tabsight/geo/data/admin1/SLB.topo.json.gz +0 -0
  228. tabsight/geo/data/admin1/SLE.topo.json.gz +0 -0
  229. tabsight/geo/data/admin1/SLV.topo.json.gz +0 -0
  230. tabsight/geo/data/admin1/SMR.topo.json.gz +0 -0
  231. tabsight/geo/data/admin1/SOL.topo.json.gz +0 -0
  232. tabsight/geo/data/admin1/SOM.topo.json.gz +0 -0
  233. tabsight/geo/data/admin1/SPM.topo.json.gz +0 -0
  234. tabsight/geo/data/admin1/SRB.topo.json.gz +0 -0
  235. tabsight/geo/data/admin1/STP.topo.json.gz +0 -0
  236. tabsight/geo/data/admin1/SUR.topo.json.gz +0 -0
  237. tabsight/geo/data/admin1/SVK.topo.json.gz +0 -0
  238. tabsight/geo/data/admin1/SVN.topo.json.gz +0 -0
  239. tabsight/geo/data/admin1/SWE.topo.json.gz +0 -0
  240. tabsight/geo/data/admin1/SWZ.topo.json.gz +0 -0
  241. tabsight/geo/data/admin1/SXM.topo.json.gz +0 -0
  242. tabsight/geo/data/admin1/SYC.topo.json.gz +0 -0
  243. tabsight/geo/data/admin1/SYR.topo.json.gz +0 -0
  244. tabsight/geo/data/admin1/TCA.topo.json.gz +0 -0
  245. tabsight/geo/data/admin1/TCD.topo.json.gz +0 -0
  246. tabsight/geo/data/admin1/TGO.topo.json.gz +0 -0
  247. tabsight/geo/data/admin1/THA.topo.json.gz +0 -0
  248. tabsight/geo/data/admin1/TJK.topo.json.gz +0 -0
  249. tabsight/geo/data/admin1/TKM.topo.json.gz +0 -0
  250. tabsight/geo/data/admin1/TLS.topo.json.gz +0 -0
  251. tabsight/geo/data/admin1/TON.topo.json.gz +0 -0
  252. tabsight/geo/data/admin1/TTO.topo.json.gz +0 -0
  253. tabsight/geo/data/admin1/TUN.topo.json.gz +0 -0
  254. tabsight/geo/data/admin1/TUR.topo.json.gz +0 -0
  255. tabsight/geo/data/admin1/TUV.topo.json.gz +0 -0
  256. tabsight/geo/data/admin1/TWN.topo.json.gz +0 -0
  257. tabsight/geo/data/admin1/TZA.topo.json.gz +0 -0
  258. tabsight/geo/data/admin1/UGA.topo.json.gz +0 -0
  259. tabsight/geo/data/admin1/UKR.topo.json.gz +0 -0
  260. tabsight/geo/data/admin1/UMI.topo.json.gz +0 -0
  261. tabsight/geo/data/admin1/URY.topo.json.gz +0 -0
  262. tabsight/geo/data/admin1/USA.topo.json.gz +0 -0
  263. tabsight/geo/data/admin1/USG.topo.json.gz +0 -0
  264. tabsight/geo/data/admin1/UZB.topo.json.gz +0 -0
  265. tabsight/geo/data/admin1/VAT.topo.json.gz +0 -0
  266. tabsight/geo/data/admin1/VCT.topo.json.gz +0 -0
  267. tabsight/geo/data/admin1/VEN.topo.json.gz +0 -0
  268. tabsight/geo/data/admin1/VGB.topo.json.gz +0 -0
  269. tabsight/geo/data/admin1/VIR.topo.json.gz +0 -0
  270. tabsight/geo/data/admin1/VNM.topo.json.gz +0 -0
  271. tabsight/geo/data/admin1/VUT.topo.json.gz +0 -0
  272. tabsight/geo/data/admin1/WLF.topo.json.gz +0 -0
  273. tabsight/geo/data/admin1/WSB.topo.json.gz +0 -0
  274. tabsight/geo/data/admin1/WSM.topo.json.gz +0 -0
  275. tabsight/geo/data/admin1/YEM.topo.json.gz +0 -0
  276. tabsight/geo/data/admin1/ZAF.topo.json.gz +0 -0
  277. tabsight/geo/data/admin1/ZMB.topo.json.gz +0 -0
  278. tabsight/geo/data/admin1/ZWE.topo.json.gz +0 -0
  279. tabsight/geo/data/cities.json.gz +0 -0
  280. tabsight/geo/data/names.json.gz +0 -0
  281. tabsight/geo/data/version.json +6 -0
  282. tabsight/geo/data/world.topo.json.gz +0 -0
  283. tabsight/geo/detection.py +505 -0
  284. tabsight/geo/names.py +198 -0
  285. tabsight/i18n/__init__.py +55 -0
  286. tabsight/i18n/de.json +50 -0
  287. tabsight/i18n/en.json +90 -0
  288. tabsight/i18n/es.json +50 -0
  289. tabsight/i18n/fr.json +50 -0
  290. tabsight/i18n/it.json +50 -0
  291. tabsight/i18n/nl.json +50 -0
  292. tabsight/i18n/pt.json +50 -0
  293. tabsight/insight.py +490 -0
  294. tabsight/io.py +279 -0
  295. tabsight/lint.py +243 -0
  296. tabsight/localserver.py +113 -0
  297. tabsight/periods.py +237 -0
  298. tabsight/plotting.py +173 -0
  299. tabsight/profile.py +743 -0
  300. tabsight/py.typed +0 -0
  301. tabsight/read.py +271 -0
  302. tabsight/recommend.py +595 -0
  303. tabsight/render/__init__.py +1 -0
  304. tabsight/render/assets.py +24 -0
  305. tabsight/render/dashboard_html.py +164 -0
  306. tabsight/render/html.py +160 -0
  307. tabsight/render/picture.py +235 -0
  308. tabsight/render/static/__init__.py +0 -0
  309. tabsight/render/static/dashboard.js +2914 -0
  310. tabsight/render/static/echarts.min.js +45 -0
  311. tabsight/render/static/tabsight.js +588 -0
  312. tabsight/render/static/topojson-client.min.js +2 -0
  313. tabsight/server.py +272 -0
  314. tabsight/settings.py +195 -0
  315. tabsight/spec.py +350 -0
  316. tabsight/tablecalc.py +122 -0
  317. tabsight/theme.py +120 -0
  318. tabsight/warnings.py +113 -0
  319. tabsight/web.py +183 -0
  320. tabsight-0.1.0.dist-info/METADATA +384 -0
  321. tabsight-0.1.0.dist-info/RECORD +328 -0
  322. tabsight-0.1.0.dist-info/WHEEL +4 -0
  323. tabsight-0.1.0.dist-info/entry_points.txt +5 -0
  324. tabsight-0.1.0.dist-info/licenses/LICENSE +21 -0
  325. tabsight-0.1.0.dist-info/licenses/NOTICE +28 -0
  326. tabsight-0.1.0.dist-info/licenses/licenses/echarts-LICENSE.txt +222 -0
  327. tabsight-0.1.0.dist-info/licenses/licenses/echarts-NOTICE.txt +5 -0
  328. tabsight-0.1.0.dist-info/licenses/licenses/topojson-client-LICENSE.txt +13 -0
tabsight/aggregate.py ADDED
@@ -0,0 +1,759 @@
1
+ """Group-by with exact re-aggregation, date buckets, top N + "Other", and missing periods.
2
+
3
+ Every result is computed through *parts*: sums and counts for
4
+ means, Σ(w·x) and Σw for weighted averages, raw values for medians and distinct counts.
5
+ Combining parts again after filtering gives exactly the same numbers as computing from
6
+ the raw rows, which is what the browser runtime will rely on. One code path is used for
7
+ both, so they cannot drift apart.
8
+
9
+ The input DataFrame is never changed.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import dataclasses
15
+ import difflib
16
+ import math
17
+ from collections.abc import Callable, Iterable, Mapping, Sequence
18
+ from typing import Any
19
+
20
+ import numpy as np
21
+ import pandas as pd
22
+
23
+ from tabsight.colors import natural_key
24
+ from tabsight.formulas import (
25
+ AGGREGATES,
26
+ CalcField,
27
+ Call,
28
+ evaluate_aggregate,
29
+ evaluate_node,
30
+ validate_params,
31
+ )
32
+ from tabsight.i18n import text
33
+ from tabsight.io import as_frame
34
+ from tabsight.profile import AGGS, Profile, _prepare, profile
35
+ from tabsight.read import read_dates, read_numbers
36
+ from tabsight.settings import resolve
37
+ from tabsight.warnings import DataWarning, InvalidSpecError, safe_repr, warn
38
+
39
+ __all__ = [
40
+ "GRAINS",
41
+ "PARTS",
42
+ "Aggregation",
43
+ "Measure",
44
+ "aggregate",
45
+ "bucket",
46
+ "build_parts",
47
+ "combine_parts",
48
+ ]
49
+
50
+ GRAINS = ("year", "quarter", "month", "week", "day", "hour", "minute", "second")
51
+ PARTS: dict[str, tuple[str, ...]] = {
52
+ "sum": ("sum",),
53
+ "count": ("count",),
54
+ "mean": ("sum", "count"),
55
+ "min": ("min",),
56
+ "max": ("max",),
57
+ "wavg": ("wsum", "wtotal"),
58
+ "countd": ("values",),
59
+ "median": ("values",),
60
+ }
61
+ ROWS = "__rows"
62
+ OTHER_FLAG = "__other"
63
+ SUPPRESSED_FLAG = "__suppressed"
64
+ MAX_FILL_ROWS = 1_000_000
65
+ MAX_PERIODS = 100_000
66
+ _NUMERIC_AGGS = {"sum", "mean", "median", "wavg"}
67
+ _ZERO_WHEN_EMPTY = {"sum", "count", "countd"}
68
+
69
+
70
+ @dataclasses.dataclass(frozen=True)
71
+ class Measure:
72
+ """One measure: a column (or ``None`` for a row count) and how to aggregate it."""
73
+
74
+ column: str | None
75
+ agg: str
76
+ weight: str | None = None
77
+ name: str | None = None
78
+
79
+ def __post_init__(self) -> None:
80
+ if self.agg not in PARTS:
81
+ raise InvalidSpecError(f"agg {safe_repr(self.agg)} must be one of {tuple(PARTS)}")
82
+ if self.agg == "wavg" and not self.weight:
83
+ raise InvalidSpecError("agg='wavg' needs weight=<column>")
84
+ if self.column is None and self.agg != "count":
85
+ raise InvalidSpecError("a measure without a column can only count rows")
86
+
87
+ @property
88
+ def label(self) -> str:
89
+ if self.name:
90
+ return self.name
91
+ if self.column is None:
92
+ return "Rows"
93
+ return self.column
94
+
95
+
96
+ @dataclasses.dataclass(frozen=True)
97
+ class Aggregation:
98
+ """The result of :func:`aggregate`: tidy rows plus what was assumed."""
99
+
100
+ frame: pd.DataFrame
101
+ dims: tuple[str, ...]
102
+ measures: tuple[Measure, ...]
103
+ other: Mapping[str, tuple[Any, ...]] = dataclasses.field(default_factory=dict)
104
+ categories: Mapping[str, int] = dataclasses.field(default_factory=dict)
105
+ notes: tuple[str, ...] = ()
106
+ suppressed: int = 0
107
+ outputs: tuple[str, ...] = () # measure and calculated-field columns, in the order asked for
108
+ partial_period: bool = False # the last date period isn't over (see notes)
109
+
110
+ @property
111
+ def values(self) -> pd.DataFrame:
112
+ """Only the dimension and output columns (no internal helper columns)."""
113
+ outputs = self.outputs or tuple(
114
+ m.label for m in self.measures if not m.label.startswith("__")
115
+ )
116
+ return self.frame[[*self.dims, *outputs]]
117
+
118
+
119
+ # ---------------------------------------------------------------- date buckets
120
+
121
+
122
+ def bucket(
123
+ values: pd.Series, grain: str, *, fiscal_start: int = 1, week_start: str = "monday"
124
+ ) -> pd.Series:
125
+ """Start of the period each date falls in (``2017-02-14`` by month → ``2017-02-01``).
126
+
127
+ Fiscal years start in month ``fiscal_start``; quarters follow the fiscal year.
128
+ """
129
+ if grain not in GRAINS:
130
+ raise InvalidSpecError(f"grain must be one of {GRAINS}")
131
+ v = pd.to_datetime(values, errors="coerce")
132
+ if grain in ("year", "quarter"):
133
+ valid = v.notna()
134
+ months = (
135
+ (v.dt.year.fillna(2000).astype("int64") * 12) + v.dt.month.fillna(1).astype("int64") - 1
136
+ )
137
+ offset = (v.dt.month.fillna(1).astype("int64") - fiscal_start) % 12
138
+ months = months - (offset % 3 if grain == "quarter" else offset)
139
+ out = pd.to_datetime(
140
+ pd.DataFrame({"year": months // 12, "month": months % 12 + 1, "day": 1}),
141
+ errors="coerce",
142
+ )
143
+ out = out.where(valid)
144
+ out.index = values.index
145
+ return out
146
+ if grain == "month":
147
+ return v.dt.to_period("M").dt.start_time.where(v.notna())
148
+ if grain == "week":
149
+ shift = {"monday": 0, "sunday": 1, "saturday": 2}[week_start]
150
+ days = (v.dt.day_of_week + shift) % 7
151
+ return v.dt.normalize() - pd.to_timedelta(days, unit="D")
152
+ if grain == "day":
153
+ return v.dt.normalize()
154
+ return v.dt.floor({"hour": "h", "minute": "min", "second": "s"}[grain])
155
+
156
+
157
+ def _period_range(start: pd.Timestamp, end: pd.Timestamp, grain: str) -> pd.DatetimeIndex:
158
+ freq = {
159
+ "year": "12MS", "quarter": "3MS", "month": "MS", "week": "7D",
160
+ "day": "D", "hour": "h", "minute": "min", "second": "s",
161
+ }[grain] # fmt: skip
162
+ return pd.date_range(start, end, freq=freq)
163
+
164
+
165
+ # ---------------------------------------------------------------- parts
166
+
167
+
168
+ def _part_name(i: int, part: str) -> str:
169
+ return f"__m{i}_{part}"
170
+
171
+
172
+ def build_parts(
173
+ frame: pd.DataFrame, dims: Sequence[str], measures: Sequence[Measure]
174
+ ) -> pd.DataFrame:
175
+ """Group ``frame`` by ``dims`` and store the re-aggregatable parts of each measure.
176
+
177
+ ``frame`` must already hold numeric measure columns (see :func:`aggregate`).
178
+ """
179
+ work = pd.DataFrame(index=frame.index)
180
+ for d in dims:
181
+ work[d] = frame[d]
182
+ for i, m in enumerate(measures):
183
+ if m.column is None:
184
+ continue
185
+ col = frame[m.column]
186
+ if m.agg == "wavg":
187
+ if m.weight is None: # also rejected by Measure itself
188
+ raise InvalidSpecError("agg='wavg' needs weight=<column>")
189
+ w = pd.to_numeric(frame[m.weight], errors="coerce")
190
+ both = col.notna() & w.notna()
191
+ work[_part_name(i, "wsum")] = (col * w).where(both)
192
+ work[_part_name(i, "wtotal")] = w.where(both)
193
+ else:
194
+ work[_part_name(i, "src")] = col
195
+ work[ROWS] = 1
196
+
197
+ if dims:
198
+ grouped = work.groupby(list(dims), dropna=False, sort=False, observed=True)
199
+ else:
200
+ grouped = work.groupby(np.zeros(len(work), dtype=np.int8), sort=False)
201
+ out = pd.DataFrame({ROWS: grouped[ROWS].sum()})
202
+ for i, m in enumerate(measures):
203
+ src = _part_name(i, "src")
204
+ if m.column is None:
205
+ out[_part_name(i, "count")] = out[ROWS]
206
+ elif m.agg == "wavg":
207
+ out[_part_name(i, "wsum")] = grouped[_part_name(i, "wsum")].sum(min_count=1)
208
+ out[_part_name(i, "wtotal")] = grouped[_part_name(i, "wtotal")].sum(min_count=1)
209
+ elif m.agg in ("sum", "mean"):
210
+ out[_part_name(i, "sum")] = grouped[src].sum(min_count=1)
211
+ if m.agg == "mean":
212
+ out[_part_name(i, "count")] = grouped[src].count()
213
+ elif m.agg == "count":
214
+ out[_part_name(i, "count")] = grouped[src].count()
215
+ elif m.agg in ("min", "max"):
216
+ out[_part_name(i, m.agg)] = getattr(grouped[src], m.agg)()
217
+ else: # countd, median: keep the values themselves
218
+ # pandas-stubs does not model tuple results from agg; behaviour is tested.
219
+ out[_part_name(i, "values")] = grouped[src].agg( # type: ignore[type-var]
220
+ lambda s: tuple(s.dropna().tolist())
221
+ )
222
+ out = out.reset_index(drop=not dims)
223
+ return out
224
+
225
+
226
+ def combine_parts(
227
+ parts: pd.DataFrame,
228
+ dims: Sequence[str],
229
+ measures: Sequence[Measure],
230
+ min_group_size: int | None = None,
231
+ ) -> pd.DataFrame:
232
+ """Re-aggregate parts to ``dims`` and compute the final measure values exactly."""
233
+ cols = [
234
+ c for c in parts.columns if c.startswith("__") and c not in dims
235
+ ] # a user column may start with __ too
236
+ if dims:
237
+ grouped = parts.groupby(list(dims), dropna=False, sort=False, observed=True)
238
+ else:
239
+ grouped = parts.groupby(np.zeros(len(parts), dtype=np.int8), sort=False)
240
+ agg: dict[str, Any] = {}
241
+ for c in cols:
242
+ if c.endswith("_min"):
243
+ agg[c] = "min"
244
+ elif c.endswith("_max"):
245
+ agg[c] = "max"
246
+ elif c.endswith("_values"):
247
+ agg[c] = lambda s: tuple(v for t in s for v in t)
248
+ elif c in (OTHER_FLAG, SUPPRESSED_FLAG):
249
+ agg[c] = "max"
250
+ else:
251
+ agg[c] = lambda s: s.sum(min_count=1)
252
+ combined = grouped.agg(agg) if agg else grouped.size().to_frame(ROWS)
253
+ combined = combined.reset_index(drop=not dims)
254
+ for i, m in enumerate(measures):
255
+ combined[m.label] = _finish(combined, i, m)
256
+ if min_group_size is not None:
257
+ small = combined[ROWS].fillna(0) < min_group_size
258
+ for m in measures:
259
+ combined[m.label] = combined[m.label].where(~small)
260
+ combined[SUPPRESSED_FLAG] = small
261
+ return combined
262
+
263
+
264
+ def _finish(frame: pd.DataFrame, i: int, m: Measure) -> pd.Series:
265
+ def part(name: str) -> pd.Series:
266
+ return frame[_part_name(i, name)]
267
+
268
+ if m.agg == "sum":
269
+ return part("sum").astype("float64")
270
+ if m.agg == "count":
271
+ return part("count").fillna(0).astype("int64")
272
+ if m.agg == "mean":
273
+ count = part("count").astype("float64")
274
+ return (part("sum").astype("float64") / count).where(count > 0)
275
+ if m.agg in ("min", "max"):
276
+ return part(m.agg)
277
+ if m.agg == "wavg":
278
+ total = part("wtotal").astype("float64")
279
+ return (part("wsum").astype("float64") / total).where(total != 0)
280
+ if m.agg == "countd":
281
+ return part("values").map(lambda t: len(set(t))).astype("int64")
282
+ return part("values").map(lambda t: float(np.median(t)) if len(t) else math.nan)
283
+
284
+
285
+ # ---------------------------------------------------------------- preparing inputs
286
+
287
+
288
+ def _unknown(name: object, names: Iterable[str]) -> InvalidSpecError:
289
+ names = list(names)
290
+ hint = difflib.get_close_matches(str(name)[:200], names, n=1)
291
+ return InvalidSpecError(
292
+ f"column {safe_repr(name)} not found"
293
+ + (f"; did you mean {safe_repr(hint[0])}?" if hint else "")
294
+ )
295
+
296
+
297
+ def _as_measures(measures: Any, prof: Profile) -> list[Measure]:
298
+ if measures is None:
299
+ return []
300
+ if isinstance(measures, str | Measure | Mapping):
301
+ measures = [measures]
302
+ out = []
303
+ for m in measures:
304
+ if isinstance(m, Measure):
305
+ out.append(m)
306
+ elif isinstance(m, str):
307
+ if m not in prof:
308
+ raise _unknown(m, prof.names())
309
+ agg = (
310
+ prof[m].agg
311
+ if prof[m].agg in PARTS
312
+ else ("countd" if prof[m].role == "id" else "count")
313
+ )
314
+ out.append(Measure(m, agg))
315
+ elif isinstance(m, Mapping):
316
+ extra = set(m) - {"column", "agg", "weight", "name"}
317
+ if extra:
318
+ raise InvalidSpecError(f"unknown measure key(s) {sorted(map(safe_repr, extra))}")
319
+ column = m.get("column")
320
+ if column is not None and not isinstance(column, str):
321
+ raise InvalidSpecError("measure 'column' must be a column name")
322
+ agg = m.get("agg") or (
323
+ prof[column].agg if column is not None and column in prof else "count"
324
+ )
325
+ if agg not in AGGS or agg == "none":
326
+ raise InvalidSpecError(f"agg {safe_repr(agg)} is not allowed for a measure")
327
+ out.append(Measure(column, agg, m.get("weight"), m.get("name")))
328
+ else:
329
+ raise InvalidSpecError(f"cannot use {type(m).__name__} as a measure")
330
+ labels = [m.label for m in out]
331
+ if len(set(labels)) != len(labels):
332
+ out = [
333
+ dataclasses.replace(m, name=f"{m.label} ({m.agg})")
334
+ if labels.count(m.label) > 1 and not m.name
335
+ else m
336
+ for m in out
337
+ ]
338
+ return out
339
+
340
+
341
+ def _numeric(col: pd.Series, field_source: str) -> pd.Series:
342
+ if field_source == "text-number":
343
+ reading = read_numbers(col, threshold=0.0)
344
+ if reading is not None:
345
+ return reading.values
346
+ values = pd.to_numeric(col, errors="coerce").astype("float64")
347
+ return values.where(np.isfinite(values))
348
+
349
+
350
+ def _time_values(col: pd.Series, prof: Profile, name: str) -> pd.Series:
351
+ field = prof[name]
352
+ if field.source == "integer-year":
353
+ years = pd.to_numeric(col, errors="coerce")
354
+ return pd.to_datetime(years.astype("Int64").astype("string") + "-01-01", errors="coerce")
355
+ if pd.api.types.is_datetime64_any_dtype(col):
356
+ return _local_time(col)
357
+ reading = read_dates(col, dayfirst=field.dayfirst, threshold=0.0)
358
+ return _local_time(
359
+ reading.values if reading is not None else pd.to_datetime(col, errors="coerce")
360
+ )
361
+
362
+
363
+ def _local_time(values: pd.Series) -> pd.Series:
364
+ """Time-zone-aware dates are grouped by their wall-clock date: in the ``tz`` setting
365
+ if one is set, otherwise in their own time zone."""
366
+ if getattr(values.dt, "tz", None) is None:
367
+ return values
368
+ from tabsight.settings import current
369
+
370
+ tz = current().tz
371
+ if tz is not None:
372
+ try:
373
+ values = values.dt.tz_convert(tz)
374
+ except Exception as err: # an unknown zone name
375
+ raise InvalidSpecError(f"unknown time zone {safe_repr(tz)}") from err
376
+ naive: pd.Series = values.dt.tz_localize(None)
377
+ return naive
378
+
379
+
380
+ # ---------------------------------------------------------------- the main entry point
381
+
382
+
383
+ def aggregate(
384
+ data: Any,
385
+ dims: str | Sequence[str] | None = None,
386
+ measures: Any = None,
387
+ *,
388
+ freq: str | Mapping[str, str] | None = None,
389
+ top: int | None = None,
390
+ sort: str | Sequence[str] | None = None,
391
+ missing: str | None = None,
392
+ min_group_size: int | None = None,
393
+ fiscal_start: int | None = None,
394
+ week_start: str | None = None,
395
+ params: Mapping[str, Any] | None = None,
396
+ prof: Profile | None = None,
397
+ ) -> Aggregation:
398
+ """Group ``data`` by ``dims`` and aggregate ``measures`` exactly.
399
+
400
+ - ``measures``: column names (aggregated by their profiled default), ``Measure``
401
+ objects or dicts like ``{"column": "Discount", "agg": "wavg", "weight": "Sales"}``.
402
+ - ``freq``: date grain for time dimensions (``"month"``), or a dict per column.
403
+ - ``top``: keep the top N values of the first category dimension (never a date
404
+ dimension); the rest become one exact "Other" row whose members are listed in
405
+ ``result.other``. Ranking uses the first requested output.
406
+ - ``sort``: ``"desc"`` / ``"asc"`` by the first measure, ``"none"`` for natural
407
+ order, ``"by:<measure>"``, or an explicit list of values.
408
+ - ``missing``: missing time periods as ``"gap"`` (NaN), ``"zero"`` or ``"connect"``
409
+ (left out).
410
+ - ``min_group_size``: hide values of groups with fewer rows (privacy).
411
+ - ``measures`` may also hold calculated fields from ``ts.calc(...)``; ``params``
412
+ supplies their ``$Name`` parameter values.
413
+ """
414
+ settings = resolve(
415
+ missing=missing,
416
+ min_group_size=min_group_size,
417
+ fiscal_start=fiscal_start,
418
+ week_start=week_start,
419
+ )
420
+ frame, _ = _prepare(as_frame(data))
421
+ prof = prof if prof is not None else profile(frame, quiet=True)
422
+ dim_list = [dims] if isinstance(dims, str) else list(dims or [])
423
+ for d in dim_list:
424
+ if d not in frame.columns:
425
+ raise _unknown(d, frame.columns)
426
+ if len(set(dim_list)) != len(dim_list):
427
+ raise InvalidSpecError("each dimension can only be used once")
428
+ if measures is None:
429
+ requested: list[Any] = []
430
+ elif isinstance(measures, list | tuple):
431
+ requested = list(measures)
432
+ else:
433
+ requested = [measures]
434
+ calcs = [m for m in requested if isinstance(m, CalcField)]
435
+ measure_list = _as_measures([m for m in requested if not isinstance(m, CalcField)], prof)
436
+ plain = iter(measure_list)
437
+ outputs = [m.name if isinstance(m, CalcField) else next(plain).label for m in requested]
438
+ if len(set(outputs)) != len(outputs):
439
+ raise InvalidSpecError(f"output names must be unique: {outputs}")
440
+ notes: list[str] = []
441
+
442
+ # Never add up different currencies: split by the currency column automatically.
443
+ codes = [f.name for f in prof.fields if f.currency_code_column and f.distinct > 1]
444
+ if (
445
+ codes
446
+ and codes[0] not in dim_list
447
+ and any(
448
+ m.column is not None and prof[m.column].unit == "currency" and m.agg in _NUMERIC_AGGS
449
+ for m in measure_list
450
+ if m.column in prof
451
+ )
452
+ ):
453
+ dim_list.append(codes[0])
454
+ note = f"split by {safe_repr(codes[0])} so different currencies are never added together"
455
+ notes.append(note)
456
+ warn(note, DataWarning)
457
+
458
+ # Working copy: only the needed columns, read as proper types.
459
+ work: dict[str, pd.Series] = {}
460
+ grains: dict[str, str] = {}
461
+ partial_period = False
462
+ for d in dim_list:
463
+ col = frame[d]
464
+ if prof[d].role == "time":
465
+ grain = freq.get(d) if isinstance(freq, Mapping) else freq
466
+ grain = grain or prof[d].grain or "day"
467
+ if grain not in GRAINS:
468
+ raise InvalidSpecError(f"freq for {safe_repr(d)} must be one of {GRAINS}")
469
+ grains[d] = grain
470
+ dates = _time_values(col, prof, d)
471
+ work[d] = bucket(
472
+ dates, grain, fiscal_start=settings.fiscal_start, week_start=settings.week_start
473
+ )
474
+ partial = _incomplete_last_period(dates, work[d], grain)
475
+ if partial:
476
+ notes.append(partial)
477
+ partial_period = True
478
+ else:
479
+ work[d] = col
480
+
481
+ # Calculated fields: row-level ones become a column; each aggregate inside an
482
+ # aggregate formula becomes a hidden exact measure (so filtering stays exact).
483
+ param_values = validate_params(params, [p for c in calcs for p in c.params])
484
+ extra: dict[str, pd.Series] = {}
485
+ agg_calcs: list[tuple[CalcField, dict[Call, str]]] = []
486
+ if calcs:
487
+ used = {f for c in calcs for f in c.fields}
488
+ typed = _typed_frame(frame, prof, used)
489
+ for i, c in enumerate(calcs):
490
+ c.check_fields(list(frame.columns))
491
+ if c.name in frame.columns:
492
+ raise InvalidSpecError(
493
+ f"calculated field {safe_repr(c.name)} has the same name as a column"
494
+ )
495
+ if c.level == "row":
496
+ extra[c.name] = _calc_numbers(evaluate_node(c.tree, typed, param_values), c.name)
497
+ measure_list.append(Measure(c.name, "sum"))
498
+ continue
499
+ mapping: dict[Call, str] = {}
500
+ for j, call in enumerate(c.aggregates):
501
+ hidden = f"__calc{i}_{j}"
502
+ values = evaluate_node(call.args[0], typed, param_values)
503
+ agg_name = AGGREGATES[call.fn]
504
+ if agg_name not in ("count", "countd"):
505
+ values = _calc_numbers(values, f"{c.name}: {call.fn}")
506
+ extra[hidden] = values
507
+ measure_list.append(Measure(hidden, agg_name, name=hidden))
508
+ mapping[call] = hidden
509
+ agg_calcs.append((c, mapping))
510
+
511
+ def finish(result: pd.DataFrame) -> pd.DataFrame:
512
+ """Evaluate aggregate formulas on combined values (and respect hidden groups)."""
513
+ if not agg_calcs:
514
+ return result
515
+ result = result.copy()
516
+ for c, mapping in agg_calcs:
517
+ values = evaluate_aggregate(
518
+ c, {call: result[h] for call, h in mapping.items()}, param_values
519
+ )
520
+ if SUPPRESSED_FLAG in result:
521
+ values = values.where(~result[SUPPRESSED_FLAG].astype(bool))
522
+ result[c.name] = values
523
+ return result
524
+
525
+ for m in measure_list:
526
+ for name in (m.column, m.weight):
527
+ if name is None or name in work:
528
+ continue
529
+ if name in extra:
530
+ work[name] = extra[name]
531
+ continue
532
+ if name not in frame.columns:
533
+ raise _unknown(name, frame.columns)
534
+ field = prof[name]
535
+ if m.agg in _NUMERIC_AGGS or name == m.weight:
536
+ if field.role in ("dimension", "text") and field.source not in ("text-number",):
537
+ raise InvalidSpecError(
538
+ f"{safe_repr(name)} is text; use agg='count' or 'countd' instead of {m.agg!r}"
539
+ )
540
+ if field.role == "id" and m.agg in ("sum", "mean"):
541
+ warn(
542
+ f"{safe_repr(name)} looks like an ID; adding up IDs is usually wrong",
543
+ DataWarning,
544
+ )
545
+ work[name] = _numeric(frame[name], field.source)
546
+ elif m.agg in ("min", "max") and field.role == "time":
547
+ work[name] = _time_values(frame[name], prof, name)
548
+ elif m.agg in ("min", "max"):
549
+ work[name] = _numeric(frame[name], field.source)
550
+ else:
551
+ work[name] = frame[name]
552
+ working = pd.DataFrame(work, index=frame.index)
553
+
554
+ parts = build_parts(working, dim_list, measure_list)
555
+ other: dict[str, tuple[Any, ...]] = {}
556
+ categories: dict[str, int] = {}
557
+ primary = outputs[0] if outputs else None
558
+ if top is not None and dim_list and primary is not None:
559
+ # Top N applies to the first category dimension (never to dates): with
560
+ # [Month, Region] it keeps the top regions across all months.
561
+ top_dim = next((d for d in dim_list if d not in grains), None)
562
+ if top_dim is None:
563
+ notes.append("top N ignored: every dimension is a date")
564
+ else:
565
+ parts, other, categories = _top_n(parts, top_dim, measure_list, top, finish, primary)
566
+ result = finish(combine_parts(parts, dim_list, measure_list, settings.min_group_size))
567
+ suppressed = int(result[SUPPRESSED_FLAG].sum()) if SUPPRESSED_FLAG in result else 0
568
+ if suppressed:
569
+ notes.append(
570
+ f"{suppressed} group(s) hidden: {text('fewer_than', n=settings.min_group_size)} rows"
571
+ )
572
+
573
+ time_dims = [d for d in dim_list if d in grains]
574
+ if time_dims and settings.missing != "connect":
575
+ result, fill_note = _fill_periods(
576
+ result, dim_list, time_dims[0], grains[time_dims[0]], measure_list, settings.missing
577
+ )
578
+ if fill_note:
579
+ notes.append(fill_note)
580
+ result = _sort(result, dim_list, primary, outputs, prof, grains, sort)
581
+ return Aggregation(
582
+ frame=result.reset_index(drop=True),
583
+ dims=tuple(dim_list),
584
+ measures=tuple(measure_list),
585
+ other=other,
586
+ categories=categories,
587
+ notes=tuple(notes),
588
+ suppressed=suppressed,
589
+ outputs=tuple(outputs),
590
+ partial_period=partial_period,
591
+ )
592
+
593
+
594
+ _PERIOD_LENGTH = {"year": 365, "quarter": 91, "month": 30, "week": 7}
595
+
596
+
597
+ def _incomplete_last_period(dates: pd.Series, buckets: pd.Series, grain: str) -> str | None:
598
+ """A note when the last period holds only part of its time span (it will look low)."""
599
+ if grain not in _PERIOD_LENGTH or dates.dropna().empty:
600
+ return None
601
+ last_start = buckets.max()
602
+ latest = dates.max()
603
+ covered_days = (latest.normalize() - last_start).days + 1
604
+ if covered_days >= 0.5 * _PERIOD_LENGTH[grain]:
605
+ return None
606
+ from tabsight.formats import format_date
607
+
608
+ return (
609
+ f"the last {grain} ({format_date(last_start, grain)}) only has data up to "
610
+ f"{format_date(latest, 'day')}, so it looks low because it is incomplete"
611
+ )
612
+
613
+
614
+ def _typed_frame(frame: pd.DataFrame, prof: Profile, names: Iterable[str]) -> pd.DataFrame:
615
+ """Columns used by formulas, read as numbers or dates where the profile says so."""
616
+ cols: dict[str, pd.Series] = {}
617
+ for name in names:
618
+ if name not in frame.columns:
619
+ continue
620
+ field = prof[name]
621
+ if field.role == "time":
622
+ cols[name] = _time_values(frame[name], prof, name)
623
+ elif field.role == "measure" or field.source == "text-number":
624
+ cols[name] = _numeric(frame[name], field.source)
625
+ else:
626
+ cols[name] = frame[name]
627
+ return pd.DataFrame(cols, index=frame.index)
628
+
629
+
630
+ def _calc_numbers(values: pd.Series, what: str) -> pd.Series:
631
+ if pd.api.types.is_bool_dtype(values):
632
+ return values.astype("float64")
633
+ numbers = pd.to_numeric(values, errors="coerce")
634
+ if numbers.notna().sum() < values.notna().sum():
635
+ raise InvalidSpecError(f"{safe_repr(what)} gives text; use COUNT or COUNTD for text")
636
+ numbers = numbers.astype("float64")
637
+ return numbers.where(np.isfinite(numbers))
638
+
639
+
640
+ def _top_n(
641
+ parts: pd.DataFrame,
642
+ dim: str,
643
+ measures: list[Measure],
644
+ n: int,
645
+ finish: Callable[[pd.DataFrame], pd.DataFrame],
646
+ key: str,
647
+ ) -> tuple[pd.DataFrame, dict[str, tuple[Any, ...]], dict[str, int]]:
648
+ if type(n) is not int or not 1 <= n <= 1000:
649
+ raise InvalidSpecError("top must be an integer 1-1000")
650
+ totals = finish(combine_parts(parts, [dim], measures))
651
+ totals = totals.assign(_k=totals[dim].astype("object").map(natural_key))
652
+ totals = totals.sort_values([key, "_k"], ascending=[False, True], na_position="last")
653
+ values = totals[dim].tolist()
654
+ count = len(values)
655
+ if count <= n + 1: # never hide a single value inside "Other"
656
+ return parts, {}, {dim: count}
657
+ keep, rest = values[:n], values[n:]
658
+ label = f"{text('other')} ({len(rest)})"
659
+ parts = parts.copy()
660
+ in_rest = ~parts[dim].isin(keep)
661
+ parts[dim] = parts[dim].astype("object").where(~in_rest, label)
662
+ parts[OTHER_FLAG] = in_rest
663
+ members = tuple(sorted(rest, key=natural_key))
664
+ return parts, {dim: members}, {dim: count}
665
+
666
+
667
+ def _fill_periods(
668
+ result: pd.DataFrame,
669
+ dims: list[str],
670
+ time_dim: str,
671
+ grain: str,
672
+ measures: list[Measure],
673
+ mode: str,
674
+ ) -> tuple[pd.DataFrame, str | None]:
675
+ times = result[time_dim].dropna()
676
+ if times.empty:
677
+ return result, None
678
+ periods = _period_range(times.min(), times.max(), grain)
679
+ if len(periods) > MAX_PERIODS:
680
+ return result, f"too many {grain}s to fill gaps ({len(periods):,}); gaps left as they are"
681
+ others = [d for d in dims if d != time_dim]
682
+ if others:
683
+ combos = result[others].drop_duplicates()
684
+ if len(combos) * len(periods) > MAX_FILL_ROWS:
685
+ return result, "too many combinations to fill missing periods; gaps left as they are"
686
+ grid = combos.merge(pd.DataFrame({time_dim: periods}), how="cross")
687
+ else:
688
+ grid = pd.DataFrame({time_dim: periods})
689
+ filled = grid.merge(result, on=[*others, time_dim], how="left") if len(grid) else result
690
+ missing_rows = filled[ROWS].isna()
691
+ if mode == "zero":
692
+ for m in measures:
693
+ if m.agg in _ZERO_WHEN_EMPTY:
694
+ filled[m.label] = filled[m.label].where(~missing_rows, 0)
695
+ filled[ROWS] = filled[ROWS].fillna(0).astype("int64")
696
+ added = int(missing_rows.sum())
697
+ return filled, (
698
+ f"{added} empty {grain}(s) shown as {'0' if mode == 'zero' else 'gaps'}" if added else None
699
+ )
700
+
701
+
702
+ def _sort(
703
+ result: pd.DataFrame,
704
+ dims: list[str],
705
+ primary: str | None,
706
+ outputs: Sequence[str],
707
+ prof: Profile,
708
+ grains: Mapping[str, str],
709
+ sort: str | Sequence[str] | None,
710
+ ) -> pd.DataFrame:
711
+ if not dims:
712
+ return result
713
+ keys: list[str] = []
714
+ asc: list[bool] = []
715
+ work = result.copy()
716
+ first = dims[0]
717
+ if isinstance(sort, list | tuple):
718
+ order = {v: i for i, v in enumerate(sort)}
719
+ work["_s"] = work[first].map(lambda v: order.get(v, len(order)))
720
+ keys.append("_s")
721
+ asc.append(True)
722
+ elif first in grains or (sort is None and prof[first].ordered) or sort == "none":
723
+ if first in grains:
724
+ work["_s"] = work[first]
725
+ elif prof[first].ordered:
726
+ order = {v: i for i, v in enumerate(prof[first].ordered or ())}
727
+ work["_s"] = work[first].map(lambda v: order.get(str(v), len(order)))
728
+ else:
729
+ work["_s"] = work[first].astype("object").map(natural_key)
730
+ keys.append("_s")
731
+ asc.append(True)
732
+ elif primary is not None:
733
+ by = primary
734
+ if isinstance(sort, str) and sort.startswith("by:"):
735
+ by = sort[3:]
736
+ if by not in outputs:
737
+ raise _unknown(by, outputs)
738
+ totals = work.groupby(first, dropna=False, sort=False, observed=True)[by].transform("sum")
739
+ work["_s"] = totals
740
+ keys.append("_s")
741
+ asc.append(sort == "asc")
742
+ if OTHER_FLAG in work:
743
+ # "Other" goes last: last overall, or last within each date when grouping by date first.
744
+ work["_other"] = work[OTHER_FLAG].eq(True)
745
+ at = 1 if first in grains else 0
746
+ keys.insert(at, "_other")
747
+ asc.insert(at, True)
748
+ work["_n"] = work[first].astype("object").map(natural_key)
749
+ keys.append("_n")
750
+ asc.append(True)
751
+ for d in dims[1:]:
752
+ col = f"_d_{d}"
753
+ work[col] = work[d] if d in grains else work[d].astype("object").map(natural_key)
754
+ keys.append(col)
755
+ asc.append(True)
756
+ work = work.sort_values(keys, ascending=asc, kind="mergesort", na_position="last")
757
+ return work.drop(
758
+ columns=[c for c in work.columns if c.startswith("_") and not c.startswith("__")]
759
+ )