docs.thor 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. class DocsCLI < Thor
  2. include Thor::Actions
  3. def self.to_s
  4. 'Docs'
  5. end
  6. def initialize(*args)
  7. require 'docs'
  8. trap('INT') { puts; exit! } # hide backtrace on ^C
  9. super
  10. end
  11. desc 'list', 'List available documentations'
  12. option :packaged, type: :boolean
  13. def list
  14. if options[:packaged]
  15. slugs = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
  16. names = find_docs_by_slugs(slugs).map do |doc|
  17. name = if doc.version?
  18. "#{doc.superclass.to_s.demodulize.underscore}@#{doc.version}"
  19. else
  20. doc.to_s.demodulize.underscore
  21. end
  22. end
  23. else
  24. names = Docs.all.flat_map do |doc|
  25. name = doc.to_s.demodulize.underscore
  26. if doc.versioned?
  27. doc.versions.map { |_doc| "#{name}@#{_doc.version}" }
  28. else
  29. name
  30. end
  31. end
  32. end
  33. output = names.join("\n")
  34. require 'tty-pager'
  35. TTY::Pager.new.page(output)
  36. end
  37. desc 'page (<doc> | <doc@version>) [path] [--verbose] [--debug]', 'Generate a page (no indexing)'
  38. option :verbose, type: :boolean
  39. option :debug, type: :boolean
  40. def page(name, path = '')
  41. unless path.empty? || path.start_with?('/')
  42. return puts 'ERROR: [path] must be an absolute path.'
  43. end
  44. Docs.install_report :image
  45. Docs.install_report :store if options[:verbose]
  46. if options[:debug]
  47. GC.disable
  48. Docs.install_report :filter, :request, :doc
  49. end
  50. name, version = name.split(/@|~/)
  51. if Docs.generate_page(name, version, path)
  52. puts 'Done'
  53. else
  54. puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
  55. end
  56. rescue Docs::DocNotFound => error
  57. handle_doc_not_found_error(error)
  58. end
  59. desc 'generate (<doc> | <doc@version>) [--verbose] [--debug] [--force] [--package]', 'Generate a documentation'
  60. option :all, type: :boolean
  61. option :verbose, type: :boolean
  62. option :debug, type: :boolean
  63. option :force, type: :boolean
  64. option :package, type: :boolean
  65. def generate(name)
  66. Docs.rescue_errors = true
  67. Docs.install_report :store if options[:verbose]
  68. Docs.install_report :scraper if options[:debug]
  69. Docs.install_report :progress_bar, :doc, :image, :requester if $stdout.tty?
  70. require 'unix_utils' if options[:package]
  71. doc = find_doc(name)
  72. if doc < Docs::UrlScraper && !options[:force]
  73. puts <<-TEXT.strip_heredoc
  74. /!\\ WARNING /!\\
  75. Some scrapers send thousands of HTTP requests in a short period of time,
  76. which can slow down the source site and trouble its maintainers.
  77. Please scrape responsibly. Don't do it unless you're modifying the code.
  78. To download the latest tested version of this documentation, run:
  79. thor docs:download #{name}\n
  80. TEXT
  81. return unless yes? 'Proceed? (y/n)'
  82. end
  83. result = if doc.version && options[:all]
  84. doc.superclass.versions.all? do |_doc|
  85. puts "==> #{_doc.version}"
  86. generate_doc(_doc, package: options[:package]).tap { puts "\n" }
  87. end
  88. else
  89. generate_doc(doc, package: options[:package])
  90. end
  91. generate_manifest if result
  92. rescue Docs::DocNotFound => error
  93. handle_doc_not_found_error(error)
  94. ensure
  95. Docs.rescue_errors = false
  96. end
  97. desc 'manifest', 'Create the manifest'
  98. def manifest
  99. generate_manifest
  100. puts 'Done'
  101. end
  102. desc 'download (<doc> <doc@version>... | --default | --installed | --all)', 'Download documentation packages'
  103. option :default, type: :boolean
  104. option :installed, type: :boolean
  105. option :all, type: :boolean
  106. def download(*names)
  107. require 'unix_utils'
  108. docs = if options[:default]
  109. Docs.defaults
  110. elsif options[:installed]
  111. Docs.installed
  112. elsif options[:all]
  113. Docs.all_versions
  114. else
  115. find_docs(names)
  116. end
  117. assert_docs(docs)
  118. download_docs(docs)
  119. generate_manifest
  120. puts 'Done'
  121. rescue Docs::DocNotFound => error
  122. handle_doc_not_found_error(error)
  123. end
  124. desc 'package <doc> <doc@version>...', 'Create documentation packages'
  125. def package(*names)
  126. require 'unix_utils'
  127. docs = find_docs(names)
  128. assert_docs(docs)
  129. docs.each(&method(:package_doc))
  130. puts 'Done'
  131. rescue Docs::DocNotFound => error
  132. handle_doc_not_found_error(error)
  133. end
  134. desc 'clean', 'Delete documentation packages'
  135. def clean
  136. File.delete(*Dir[File.join Docs.store_path, '*.tar.gz'])
  137. puts 'Done'
  138. end
  139. desc 'upload', '[private]'
  140. option :dryrun, type: :boolean
  141. option :packaged, type: :boolean
  142. def upload(*names)
  143. if options[:packaged]
  144. slugs = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
  145. docs = find_docs_by_slugs(slugs)
  146. else
  147. docs = find_docs(names)
  148. end
  149. assert_docs(docs)
  150. # Verify files are present
  151. docs.each do |doc|
  152. unless Dir.exist?(File.join(Docs.store_path, doc.path))
  153. puts "ERROR: directory #{File.join(Docs.store_path, doc.path)} not found."
  154. return
  155. end
  156. unless File.exist?(File.join(Docs.store_path, "#{doc.path}.tar.gz"))
  157. puts "ERROR: package for '#{doc.slug}' documentation not found. Run 'thor docs:package #{doc.slug}' to create it."
  158. return
  159. end
  160. end
  161. # Sync files with S3 (used by the web app)
  162. puts '[S3] Begin syncing.'
  163. docs.each do |doc|
  164. puts "[S3] Syncing #{doc.path}..."
  165. cmd = "aws s3 sync #{File.join(Docs.store_path, doc.path)} s3://devdocs-documents/#{doc.path} --delete --profile devdocs"
  166. cmd << ' --dryrun' if options[:dryrun]
  167. system(cmd)
  168. end
  169. puts '[S3] Done syncing.'
  170. # Upload packages to downloads.devdocs.io (used by the "thor docs:download" command)
  171. puts '[S3 bundle] Begin uploading.'
  172. docs.each do |doc|
  173. filename = "#{doc.path}.tar.gz"
  174. puts "[S3 bundle] Uploading #{filename}..."
  175. cmd = "aws s3 cp #{File.join(Docs.store_path, filename)} s3://devdocs-downloads/#{filename} --profile devdocs"
  176. cmd << ' --dryrun' if options[:dryrun]
  177. system(cmd)
  178. end
  179. puts '[S3 bundle] Done uploading.'
  180. end
  181. desc 'commit', '[private]'
  182. option :message, type: :string
  183. option :amend, type: :boolean
  184. def commit(name)
  185. doc = Docs.find(name, false)
  186. message = options[:message] || "Update #{doc.name} documentation (#{doc.versions.first.release})"
  187. amend = " --amend" if options[:amend]
  188. system("git add assets/ *#{name}*") && system("git commit -m '#{message}'#{amend}")
  189. rescue Docs::DocNotFound => error
  190. handle_doc_not_found_error(error)
  191. end
  192. desc 'prepare_deploy', 'Internal task executed before deployment'
  193. def prepare_deploy
  194. puts 'Docs -- BEGIN'
  195. require 'open-uri'
  196. require 'thread'
  197. docs = Docs.all_versions
  198. time = Time.now.to_i
  199. mutex = Mutex.new
  200. (1..6).map do
  201. Thread.new do
  202. while doc = docs.shift
  203. dir = File.join(Docs.store_path, doc.path)
  204. FileUtils.mkpath(dir)
  205. ['index.json', 'meta.json'].each do |filename|
  206. json = "https://documents.devdocs.io/#{doc.path}/#{filename}?#{time}"
  207. begin
  208. URI.open(json) do |file|
  209. mutex.synchronize do
  210. path = File.join(dir, filename)
  211. File.write(path, file.read)
  212. end
  213. end
  214. rescue => e
  215. puts "Docs -- Failed to download #{json}!"
  216. throw e
  217. end
  218. end
  219. puts "Docs -- Downloaded #{doc.slug}"
  220. end
  221. end
  222. end.map(&:join)
  223. puts 'Docs -- Generating manifest...'
  224. generate_manifest
  225. puts 'Docs -- DONE'
  226. end
  227. private
  228. def find_doc(name)
  229. name, version = name.split(/@|~/)
  230. if version == 'all'
  231. Docs.find(name, false).versions
  232. else
  233. Docs.find(name, version)
  234. end
  235. end
  236. def find_docs(names)
  237. names.flat_map {|name| find_doc(name)}
  238. end
  239. def find_docs_by_slugs(slugs)
  240. slugs.flat_map do |slug|
  241. slug, version = slug.split(/~/)
  242. Docs.find_by_slug(slug, version)
  243. end
  244. end
  245. def assert_docs(docs)
  246. if docs.empty?
  247. puts 'ERROR: called with no arguments.'
  248. puts 'Run "thor list" for usage patterns.'
  249. exit
  250. end
  251. end
  252. def handle_doc_not_found_error(error)
  253. puts %(ERROR: #{error}.)
  254. puts 'Run "thor docs:list" to see the list of docs and versions.'
  255. end
  256. def generate_doc(doc, package: nil)
  257. if Docs.generate(doc)
  258. package_doc(doc) if package
  259. puts 'Done'
  260. true
  261. else
  262. puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
  263. false
  264. end
  265. end
  266. def download_docs(docs)
  267. # Don't allow downloaded files to be created as StringIO
  268. require 'open-uri'
  269. OpenURI::Buffer.send :remove_const, 'StringMax' if OpenURI::Buffer.const_defined?('StringMax')
  270. OpenURI::Buffer.const_set 'StringMax', 0
  271. require 'thread'
  272. length = docs.length
  273. mutex = Mutex.new
  274. i = 0
  275. (1..4).map do
  276. Thread.new do
  277. while doc = docs.shift
  278. status = begin
  279. download_doc(doc)
  280. 'OK'
  281. rescue => e
  282. "FAILED (#{e.class}: #{e.message})"
  283. end
  284. mutex.synchronize { puts "(#{i += 1}/#{length}) #{doc.name}#{ " #{doc.version}" if doc.version} #{status}" }
  285. end
  286. end
  287. end.map(&:join)
  288. end
  289. def download_doc(doc)
  290. target_path = File.join(Docs.store_path, doc.path)
  291. URI.open "https://downloads.devdocs.io/#{doc.path}.tar.gz" do |file|
  292. FileUtils.mkpath(target_path)
  293. file.close
  294. tar = UnixUtils.gunzip(file.path)
  295. dir = UnixUtils.untar(tar)
  296. FileUtils.rm(tar)
  297. FileUtils.rm_rf(target_path)
  298. FileUtils.mv(dir, target_path)
  299. FileUtils.rm(file.path)
  300. end
  301. end
  302. def package_doc(doc)
  303. path = File.join Docs.store_path, doc.path
  304. if File.exist?(path)
  305. tar = UnixUtils.tar(path)
  306. gzip = UnixUtils.gzip(tar)
  307. FileUtils.mv(gzip, "#{path}.tar.gz")
  308. FileUtils.rm(tar)
  309. else
  310. puts %(ERROR: can't find "#{doc.name}" documentation files.)
  311. end
  312. end
  313. def generate_manifest
  314. Docs.generate_manifest
  315. end
  316. end